refactor(imessage): remove redundant contact-summary enrichment
The contact-summary sweep generated a 3-field digest (mostRecently / overallSummary / recap) per iMessage contact via the model-boss chat endpoint. It's redundant with the prospector, which already classifies 1271 prospects with tier + archetype + score + status — strictly richer per-person intel for the contacts that matter. It was also the path that wedged the server against the decommissioned model-boss host (2026-06-23). Remove the generation path entirely: the per-sync sweep in ingestContacts, the contact-summary feature module + its test, and the now-orphaned chatJson client in shared/model-boss.ts (contact-summary was its only caller). The connection circuit breaker stays — the embedding-worker still calls the same coordinator and needs the same wedge protection. Kept the read-side data layer (summary_data column, summaryData field, updateContactSummary, the /my/contacts surface field) dormant as the landing spot if summaries are ever repopulated offline via batch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1ebbd8e872
commit
464bbbd48d
4 changed files with 5 additions and 695 deletions
|
|
@ -1,166 +0,0 @@
|
|||
/**
|
||||
* Contact summary generator.
|
||||
*
|
||||
* Given a contact and their recent message history, produces a three-part
|
||||
* structured digest via model-boss:
|
||||
* mostRecently — 1 line: what they most recently discussed
|
||||
* overallSummary — 1-3 sentences: relationship / communication style
|
||||
* recap — 1 sentence: who this person is
|
||||
*
|
||||
* The `chat` parameter is injectable for testing — pass a stub instead of the
|
||||
* real model-boss `chatJson` function.
|
||||
*/
|
||||
|
||||
import type { Pool } from 'pg';
|
||||
import { pgAll } from '@/shared/db';
|
||||
import type { ContactSummaryData } from '@/entities/contact';
|
||||
|
||||
const SUMMARY_SCHEMA: Record<string, unknown> = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mostRecently: { type: 'string' },
|
||||
overallSummary: { type: 'string' },
|
||||
recap: { type: 'string' },
|
||||
},
|
||||
required: ['mostRecently', 'overallSummary', 'recap'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const SYSTEM_PROMPT = `You summarise a person based on iMessage conversation history.
|
||||
Respond with a JSON object matching the schema. Be concise and specific — no filler phrases.
|
||||
|
||||
Fields:
|
||||
- mostRecently: one sentence describing what was most recently discussed (start with a verb, e.g. "Planned a dinner…")
|
||||
- overallSummary: 1–3 sentences describing the relationship, communication style, and recurring topics
|
||||
- recap: one sentence identifying who this person is (role/relationship)`;
|
||||
|
||||
interface MessageRow {
|
||||
body: string;
|
||||
is_from_me: boolean;
|
||||
sent_at: string;
|
||||
sender_display_name: string | null;
|
||||
}
|
||||
|
||||
export type ChatFn = (
|
||||
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>,
|
||||
opts: {
|
||||
responseSchema?: Record<string, unknown>;
|
||||
schemaName?: string;
|
||||
parse: (raw: unknown) => ContactSummaryData;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
) => Promise<ContactSummaryData>;
|
||||
|
||||
function parseSummary(raw: unknown): ContactSummaryData {
|
||||
if (raw === null || typeof raw !== 'object') throw new Error('expected object');
|
||||
const obj = raw as Record<string, unknown>;
|
||||
if (typeof obj.mostRecently !== 'string') throw new Error('missing mostRecently');
|
||||
if (typeof obj.overallSummary !== 'string') throw new Error('missing overallSummary');
|
||||
if (typeof obj.recap !== 'string') throw new Error('missing recap');
|
||||
return {
|
||||
mostRecently: obj.mostRecently || null,
|
||||
overallSummary: obj.overallSummary || null,
|
||||
recap: obj.recap || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRecentMessages(
|
||||
pool: Pool,
|
||||
deviceId: string,
|
||||
handles: string[],
|
||||
limit = 30,
|
||||
): Promise<MessageRow[]> {
|
||||
if (handles.length === 0) return [];
|
||||
|
||||
const placeholders = handles.map((_, i) => `$${i + 3}`).join(', ');
|
||||
|
||||
return pgAll<MessageRow>(
|
||||
pool,
|
||||
`SELECT m.body, m.is_from_me, m.sent_at, m.sender_display_name
|
||||
FROM macsync.messages m
|
||||
JOIN macsync.conversations c ON c.id = m.conversation_id
|
||||
WHERE c.device_id = $1
|
||||
AND m.body <> ''
|
||||
AND (
|
||||
m.from_handle IN (${placeholders})
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements_text(c.participants) p
|
||||
WHERE p IN (${placeholders})
|
||||
)
|
||||
)
|
||||
ORDER BY m.sent_at DESC
|
||||
LIMIT $2`,
|
||||
[deviceId, limit, ...handles],
|
||||
);
|
||||
}
|
||||
|
||||
function formatMessages(rows: MessageRow[], contactName: string): string {
|
||||
if (rows.length === 0) return '(no messages available)';
|
||||
|
||||
return rows
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((r) => {
|
||||
const speaker = r.is_from_me ? 'Me' : (r.sender_display_name ?? contactName);
|
||||
const date = new Date(r.sent_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
const body = r.body.slice(0, 300).replace(/\n+/g, ' ');
|
||||
return `[${date}] ${speaker}: ${body}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export interface ContactInput {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
handle: string;
|
||||
displayName: string;
|
||||
phoneNumbers: readonly string[];
|
||||
emails: readonly string[];
|
||||
}
|
||||
|
||||
export async function generateContactSummary(
|
||||
pool: Pool,
|
||||
contact: ContactInput,
|
||||
chat: ChatFn,
|
||||
): Promise<ContactSummaryData | null> {
|
||||
const handles = [contact.handle, ...contact.phoneNumbers, ...contact.emails].filter(
|
||||
(h, i, arr) => Boolean(h) && arr.indexOf(h) === i,
|
||||
);
|
||||
|
||||
let rows: MessageRow[];
|
||||
try {
|
||||
rows = await fetchRecentMessages(pool, contact.deviceId, handles);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`generateContactSummary: failed to fetch messages for ${contact.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { mostRecently: null, overallSummary: null, recap: null };
|
||||
}
|
||||
|
||||
const transcript = formatMessages(rows, contact.displayName || contact.handle);
|
||||
const userMessage = `Contact: ${contact.displayName || contact.handle} (${contact.handle})\n\nRecent messages (oldest first):\n${transcript}`;
|
||||
|
||||
try {
|
||||
return await chat(
|
||||
[
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userMessage },
|
||||
],
|
||||
{
|
||||
responseSchema: SUMMARY_SCHEMA,
|
||||
schemaName: 'contact_summary',
|
||||
parse: parseSummary,
|
||||
timeoutMs: 45_000,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`generateContactSummary: model-boss call failed for ${contact.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { randomUUIDv7 } from 'bun';
|
||||
|
||||
import { logger } from '@/shared/logger';
|
||||
import { chatJson, isCoordinatorAvailable } from '@/shared/model-boss';
|
||||
|
||||
import { getDb, pgAll } from '@/shared/db';
|
||||
import { touchDevice } from '@/entities/device';
|
||||
|
|
@ -15,7 +14,7 @@ import {
|
|||
} from '@/entities/message';
|
||||
import { upsertAttachment } from '@/entities/attachment';
|
||||
import { decodeAttributedBody } from '@/shared/typedstream/decode';
|
||||
import { upsertContact, listContactsByDevice, listAllContacts, countContactsByDevice, updateContactSummary, searchContacts } from '@/entities/contact';
|
||||
import { upsertContact, listContactsByDevice, listAllContacts, countContactsByDevice, searchContacts } from '@/entities/contact';
|
||||
import type { Conversation } from '@/entities/conversation';
|
||||
import type {
|
||||
Message,
|
||||
|
|
@ -25,7 +24,6 @@ import type {
|
|||
SearchResult,
|
||||
} from '@/entities/message';
|
||||
import type { Contact } from '@/entities/contact';
|
||||
import { generateContactSummary } from '@/features/contact-summary';
|
||||
import { listInboundSince } from '@/entities/message';
|
||||
import { listActiveConversations, type ActiveConversation } from '@/entities/conversation';
|
||||
|
||||
|
|
@ -179,7 +177,6 @@ export async function ingestMessages(deviceId: string, payload: SyncMessagesPayl
|
|||
export async function ingestContacts(deviceId: string, payload: SyncContactsPayload): Promise<{ synced: number }> {
|
||||
const pool = getDb();
|
||||
let synced = 0;
|
||||
const upsertedContacts: Contact[] = [];
|
||||
|
||||
for (const c of payload.contacts) {
|
||||
const handle = c.appleId ?? c.phoneNumber ?? c.email ?? c.displayName;
|
||||
|
|
@ -197,38 +194,9 @@ export async function ingestContacts(deviceId: string, payload: SyncContactsPayl
|
|||
emails,
|
||||
rawData: JSON.stringify(c),
|
||||
});
|
||||
upsertedContacts.push(contact);
|
||||
synced++;
|
||||
}
|
||||
|
||||
// Fire-and-forget: generate summaries sequentially to avoid overwhelming model-boss.
|
||||
void (async () => {
|
||||
for (const contact of upsertedContacts) {
|
||||
// Stop the sweep the moment the coordinator looks down — the breaker has
|
||||
// already tripped, so the rest of the batch would just queue doomed calls
|
||||
// (and pointless per-contact DB reads). It auto-resumes on a later sync
|
||||
// once the cooldown elapses and a probe succeeds.
|
||||
if (!isCoordinatorAvailable()) {
|
||||
logger.warn('contact-summary: skipping remaining contacts — model-boss circuit open', {
|
||||
remaining: upsertedContacts.length - upsertedContacts.indexOf(contact),
|
||||
});
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const summaryData = await generateContactSummary(pool, contact, chatJson);
|
||||
if (summaryData) {
|
||||
await updateContactSummary(pool, contact.id, summaryData);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('contact-summary: generation failed', {
|
||||
contactId: contact.id,
|
||||
handle: contact.handle,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return { synced };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,9 +17,10 @@ export const EMBEDDING_MODEL_VERSION = 'nomic-embed-text-v1.5@q8_0';
|
|||
*
|
||||
* The coordinator's GPU host can be offline for long stretches. Without a
|
||||
* breaker, every call pays the full TCP-connect timeout before failing, and a
|
||||
* sweep over thousands of contacts serialises thousands of slow failures — that
|
||||
* pileup is enough to stall the whole server (observed 2026-06-23: ingest froze
|
||||
* for hours while contact-summary churned ~1700 doomed 3s-timeout calls). The
|
||||
* batch of embedding calls serialises into a pile of slow failures — enough to
|
||||
* stall the whole server (this first wedged ingest for hours on 2026-06-23, via
|
||||
* a contact-summary sweep of ~1700 doomed 3s-timeout calls; that sweep has since
|
||||
* been removed, but the embedding-worker calls the same coordinator). The
|
||||
* breaker trips after a few consecutive *connection* failures, then fails fast
|
||||
* for a cooldown window, letting a single probe through afterwards (half-open)
|
||||
* to auto-recover when the host returns. HTTP error responses (4xx/5xx) count as
|
||||
|
|
@ -30,11 +31,6 @@ const CIRCUIT_COOLDOWN_MS = 60_000;
|
|||
let circuitFailureCount = 0;
|
||||
let circuitOpenUntil = 0;
|
||||
|
||||
/** True when the breaker is closed (calls allowed). Callers can skip doomed work. */
|
||||
export function isCoordinatorAvailable(): boolean {
|
||||
return Date.now() >= circuitOpenUntil;
|
||||
}
|
||||
|
||||
function recordCoordinatorReachable(): void {
|
||||
circuitFailureCount = 0;
|
||||
circuitOpenUntil = 0;
|
||||
|
|
@ -141,87 +137,3 @@ export async function embedText(text: string): Promise<number[]> {
|
|||
return vec;
|
||||
}
|
||||
|
||||
export type ChatRole = 'system' | 'user' | 'assistant';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface OpenAIChatCompletion {
|
||||
choices: Array<{
|
||||
message: { content: string };
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat completion request to model-boss and return a parsed structured value.
|
||||
*
|
||||
* Uses the OpenAI-compatible `/v1/chat/completions` endpoint.
|
||||
* Pass a JSON Schema via `responseSchema` to request structured JSON output from the model.
|
||||
*/
|
||||
export async function chatJson<T>(
|
||||
messages: ChatMessage[],
|
||||
opts: {
|
||||
model?: string;
|
||||
responseSchema?: Record<string, unknown>;
|
||||
schemaName?: string;
|
||||
parse: (raw: unknown) => T;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
): Promise<T> {
|
||||
const timeoutMs = opts.timeoutMs ?? 60_000;
|
||||
const body: Record<string, unknown> = {
|
||||
model: opts.model ?? 'auto',
|
||||
messages,
|
||||
};
|
||||
if (opts.responseSchema) {
|
||||
body.response_format = {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: opts.schemaName ?? 'response',
|
||||
schema: opts.responseSchema,
|
||||
strict: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
assertCircuitClosed('chatJson');
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${COORDINATOR_URL}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'mac-sync-server' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
recordCoordinatorUnreachable();
|
||||
throw new Error(
|
||||
`chatJson: model-boss coordinator unreachable at ${COORDINATOR_URL}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
recordCoordinatorReachable();
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`chatJson: model-boss returned ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const completion = (await res.json()) as OpenAIChatCompletion;
|
||||
const content = completion.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('chatJson: model-boss response missing choices[0].message.content');
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(content);
|
||||
} catch {
|
||||
throw new Error(`chatJson: model-boss content was not valid JSON: ${content.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
return opts.parse(payload);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,404 +0,0 @@
|
|||
/**
|
||||
* Unit tests for `generateContactSummary`.
|
||||
*
|
||||
* Seeds messages via real repo functions against `icloud.*` tables (same
|
||||
* pattern as message-search.repo.test.ts). The LLM chat callable is always
|
||||
* stubbed — no model-boss calls are made.
|
||||
*
|
||||
* Requires: QUINN_MACSYNC_DB_URL pointing at a reachable Postgres instance.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { randomUUIDv7 } from 'bun';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
import { bulkUpsertMessages } from '@/entities/message/repo';
|
||||
import { pgRun } from '@/shared/db';
|
||||
import { generateContactSummary } from '@/features/contact-summary';
|
||||
import type { ChatFn, ContactInput } from '@/features/contact-summary';
|
||||
import type { ContactSummaryData } from '@/entities/contact';
|
||||
|
||||
import { setupTestDb, teardownTestDb, type TestDb } from './db-harness';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let testDb: TestDb;
|
||||
const seededDeviceIds: string[] = [];
|
||||
|
||||
function uq(): string {
|
||||
return randomUUIDv7().replace(/-/g, '').slice(0, 8);
|
||||
}
|
||||
|
||||
async function seedDevice(pool: Pool): Promise<string> {
|
||||
const id = randomUUIDv7();
|
||||
await pgRun(
|
||||
pool,
|
||||
`INSERT INTO macsync.devices (id, name, token, platform)
|
||||
VALUES ($1, 'summary-test-device', $2, 'macos')`,
|
||||
[id, `tok-summary-${id}`],
|
||||
);
|
||||
seededDeviceIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function seedConversation(pool: Pool, deviceId: string, handle: string): Promise<string> {
|
||||
const id = randomUUIDv7();
|
||||
await pgRun(
|
||||
pool,
|
||||
`INSERT INTO macsync.conversations (id, device_id, external_id, display_name, is_group, participants)
|
||||
VALUES ($1, $2, $3, 'Summary Test Convo', false, $4::jsonb)`,
|
||||
[id, deviceId, `ext-summary-${id}`, JSON.stringify([handle])],
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
function makeContact(
|
||||
deviceId: string,
|
||||
handle: string,
|
||||
overrides: Partial<ContactInput> = {},
|
||||
): ContactInput {
|
||||
return {
|
||||
id: randomUUIDv7(),
|
||||
deviceId,
|
||||
handle,
|
||||
displayName: 'Test Contact',
|
||||
phoneNumbers: [],
|
||||
emails: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const STUB_SUMMARY: ContactSummaryData = {
|
||||
mostRecently: 'Discussed the weekend plans.',
|
||||
overallSummary: 'Close friend who messages frequently about social events.',
|
||||
recap: 'Best friend from university.',
|
||||
};
|
||||
|
||||
function makeChatStub(returnValue: ContactSummaryData = STUB_SUMMARY): {
|
||||
stub: ChatFn;
|
||||
calls: Array<{ messages: Parameters<ChatFn>[0]; opts: Parameters<ChatFn>[1] }>;
|
||||
} {
|
||||
const calls: Array<{ messages: Parameters<ChatFn>[0]; opts: Parameters<ChatFn>[1] }> = [];
|
||||
const stub: ChatFn = async (messages, opts) => {
|
||||
calls.push({ messages, opts });
|
||||
return returnValue;
|
||||
};
|
||||
return { stub, calls };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup / teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeAll(async () => {
|
||||
testDb = await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (seededDeviceIds.length > 0) {
|
||||
const placeholders = seededDeviceIds.map((_, i) => `$${i + 1}`).join(', ');
|
||||
await pgRun(
|
||||
testDb.pool,
|
||||
`DELETE FROM macsync.messages WHERE device_id IN (${placeholders})`,
|
||||
seededDeviceIds,
|
||||
);
|
||||
await pgRun(
|
||||
testDb.pool,
|
||||
`DELETE FROM macsync.conversations WHERE device_id IN (${placeholders})`,
|
||||
seededDeviceIds,
|
||||
);
|
||||
await pgRun(
|
||||
testDb.pool,
|
||||
`DELETE FROM macsync.devices WHERE id IN (${placeholders})`,
|
||||
seededDeviceIds,
|
||||
);
|
||||
}
|
||||
await teardownTestDb(testDb);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('generateContactSummary', () => {
|
||||
it('returns all-null summary when no messages exist for the contact', async () => {
|
||||
const deviceId = await seedDevice(testDb.pool);
|
||||
const contact = makeContact(deviceId, `+1555${uq().slice(0, 7)}`);
|
||||
const { stub, calls } = makeChatStub();
|
||||
|
||||
const result = await generateContactSummary(testDb.pool, contact, stub);
|
||||
|
||||
expect(result).toEqual({ mostRecently: null, overallSummary: null, recap: null });
|
||||
expect(calls.length).toBe(0); // chat must NOT be called when there are no messages
|
||||
});
|
||||
|
||||
it('calls chat with system + user messages when messages exist', async () => {
|
||||
const deviceId = await seedDevice(testDb.pool);
|
||||
const handle = `+1555${uq().slice(0, 7)}`;
|
||||
const convId = await seedConversation(testDb.pool, deviceId, handle);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await bulkUpsertMessages(testDb.pool, [
|
||||
{
|
||||
id: randomUUIDv7(),
|
||||
deviceId,
|
||||
conversationId: convId,
|
||||
externalId: `guid-sum-${randomUUIDv7()}`,
|
||||
provider: 'imessage',
|
||||
fromHandle: handle,
|
||||
body: `Hey, want to grab coffee on ${uq()}?`,
|
||||
isFromMe: false,
|
||||
hasAttachments: false,
|
||||
attachmentCount: 0,
|
||||
sentAt: now,
|
||||
},
|
||||
]);
|
||||
|
||||
const contact = makeContact(deviceId, handle);
|
||||
const { stub, calls } = makeChatStub();
|
||||
|
||||
await generateContactSummary(testDb.pool, contact, stub);
|
||||
|
||||
expect(calls.length).toBe(1);
|
||||
const [call] = calls;
|
||||
expect(call!.messages[0]!.role).toBe('system');
|
||||
expect(call!.messages[1]!.role).toBe('user');
|
||||
// User message must contain the contact name and the message body
|
||||
expect(call!.messages[1]!.content).toContain('Test Contact');
|
||||
expect(call!.messages[1]!.content).toContain('coffee');
|
||||
});
|
||||
|
||||
it('returns the stub summary result as-is', async () => {
|
||||
const deviceId = await seedDevice(testDb.pool);
|
||||
const handle = `+1555${uq().slice(0, 7)}`;
|
||||
const convId = await seedConversation(testDb.pool, deviceId, handle);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await bulkUpsertMessages(testDb.pool, [
|
||||
{
|
||||
id: randomUUIDv7(),
|
||||
deviceId,
|
||||
conversationId: convId,
|
||||
externalId: `guid-ret-${randomUUIDv7()}`,
|
||||
provider: 'imessage',
|
||||
fromHandle: handle,
|
||||
body: `Message for return value test ${uq()}`,
|
||||
isFromMe: false,
|
||||
hasAttachments: false,
|
||||
attachmentCount: 0,
|
||||
sentAt: now,
|
||||
},
|
||||
]);
|
||||
|
||||
const contact = makeContact(deviceId, handle);
|
||||
const { stub } = makeChatStub(STUB_SUMMARY);
|
||||
|
||||
const result = await generateContactSummary(testDb.pool, contact, stub);
|
||||
|
||||
expect(result).toEqual(STUB_SUMMARY);
|
||||
});
|
||||
|
||||
it('deduplicates handles across handle, phoneNumbers, and emails', async () => {
|
||||
const deviceId = await seedDevice(testDb.pool);
|
||||
// Same value appears as both handle and phoneNumbers[0]
|
||||
const handle = `+1555${uq().slice(0, 7)}`;
|
||||
const convId = await seedConversation(testDb.pool, deviceId, handle);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await bulkUpsertMessages(testDb.pool, [
|
||||
{
|
||||
id: randomUUIDv7(),
|
||||
deviceId,
|
||||
conversationId: convId,
|
||||
externalId: `guid-dedup-${randomUUIDv7()}`,
|
||||
provider: 'imessage',
|
||||
fromHandle: handle,
|
||||
body: `Dedup test message ${uq()}`,
|
||||
isFromMe: false,
|
||||
hasAttachments: false,
|
||||
attachmentCount: 0,
|
||||
sentAt: now,
|
||||
},
|
||||
]);
|
||||
|
||||
const contact = makeContact(deviceId, handle, {
|
||||
phoneNumbers: [handle], // duplicate of handle
|
||||
});
|
||||
const { stub, calls } = makeChatStub();
|
||||
|
||||
// Should not throw (no duplicate placeholders in SQL)
|
||||
await generateContactSummary(testDb.pool, contact, stub);
|
||||
|
||||
expect(calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('matches messages by email handle', async () => {
|
||||
const deviceId = await seedDevice(testDb.pool);
|
||||
const email = `user${uq()}@example.com`;
|
||||
const convId = await seedConversation(testDb.pool, deviceId, email);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await bulkUpsertMessages(testDb.pool, [
|
||||
{
|
||||
id: randomUUIDv7(),
|
||||
deviceId,
|
||||
conversationId: convId,
|
||||
externalId: `guid-email-${randomUUIDv7()}`,
|
||||
provider: 'imessage',
|
||||
fromHandle: email,
|
||||
body: `iMessage via email ${uq()}`,
|
||||
isFromMe: false,
|
||||
hasAttachments: false,
|
||||
attachmentCount: 0,
|
||||
sentAt: now,
|
||||
},
|
||||
]);
|
||||
|
||||
const contact = makeContact(deviceId, `+1555${uq().slice(0, 7)}`, {
|
||||
emails: [email], // handle doesn't match but email does
|
||||
});
|
||||
const { stub, calls } = makeChatStub();
|
||||
|
||||
await generateContactSummary(testDb.pool, contact, stub);
|
||||
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0]!.messages[1]!.content).toContain('iMessage via email');
|
||||
});
|
||||
|
||||
it('includes both sent and received messages in the transcript', async () => {
|
||||
const deviceId = await seedDevice(testDb.pool);
|
||||
const handle = `+1555${uq().slice(0, 7)}`;
|
||||
const convId = await seedConversation(testDb.pool, deviceId, handle);
|
||||
const now = new Date().toISOString();
|
||||
const uniqueOutgoing = `outgoing${uq()}`;
|
||||
|
||||
await bulkUpsertMessages(testDb.pool, [
|
||||
{
|
||||
id: randomUUIDv7(),
|
||||
deviceId,
|
||||
conversationId: convId,
|
||||
externalId: `guid-recv-${randomUUIDv7()}`,
|
||||
provider: 'imessage',
|
||||
fromHandle: handle,
|
||||
body: `Incoming message ${uq()}`,
|
||||
isFromMe: false,
|
||||
hasAttachments: false,
|
||||
attachmentCount: 0,
|
||||
sentAt: now,
|
||||
},
|
||||
{
|
||||
id: randomUUIDv7(),
|
||||
deviceId,
|
||||
conversationId: convId,
|
||||
externalId: `guid-sent-${randomUUIDv7()}`,
|
||||
provider: 'imessage',
|
||||
fromHandle: '',
|
||||
body: uniqueOutgoing,
|
||||
isFromMe: true,
|
||||
hasAttachments: false,
|
||||
attachmentCount: 0,
|
||||
sentAt: now,
|
||||
},
|
||||
]);
|
||||
|
||||
const contact = makeContact(deviceId, handle);
|
||||
const { stub, calls } = makeChatStub();
|
||||
|
||||
await generateContactSummary(testDb.pool, contact, stub);
|
||||
|
||||
const userContent = calls[0]!.messages[1]!.content;
|
||||
// Outgoing message appears via participant match, labelled "Me"
|
||||
expect(userContent).toContain(uniqueOutgoing);
|
||||
expect(userContent).toContain('Me:');
|
||||
});
|
||||
|
||||
it('wraps chat errors in a descriptive error', async () => {
|
||||
const deviceId = await seedDevice(testDb.pool);
|
||||
const handle = `+1555${uq().slice(0, 7)}`;
|
||||
const convId = await seedConversation(testDb.pool, deviceId, handle);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await bulkUpsertMessages(testDb.pool, [
|
||||
{
|
||||
id: randomUUIDv7(),
|
||||
deviceId,
|
||||
conversationId: convId,
|
||||
externalId: `guid-err-${randomUUIDv7()}`,
|
||||
provider: 'imessage',
|
||||
fromHandle: handle,
|
||||
body: `Error test ${uq()}`,
|
||||
isFromMe: false,
|
||||
hasAttachments: false,
|
||||
attachmentCount: 0,
|
||||
sentAt: now,
|
||||
},
|
||||
]);
|
||||
|
||||
const failingChat: ChatFn = async () => {
|
||||
throw new Error('model-boss timeout');
|
||||
};
|
||||
const contact = makeContact(deviceId, handle);
|
||||
|
||||
await expect(generateContactSummary(testDb.pool, contact, failingChat)).rejects.toThrow(
|
||||
/model-boss call failed/,
|
||||
);
|
||||
});
|
||||
|
||||
it('empty-string fields from chat are coerced to null', async () => {
|
||||
const deviceId = await seedDevice(testDb.pool);
|
||||
const handle = `+1555${uq().slice(0, 7)}`;
|
||||
const convId = await seedConversation(testDb.pool, deviceId, handle);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await bulkUpsertMessages(testDb.pool, [
|
||||
{
|
||||
id: randomUUIDv7(),
|
||||
deviceId,
|
||||
conversationId: convId,
|
||||
externalId: `guid-empty-${randomUUIDv7()}`,
|
||||
provider: 'imessage',
|
||||
fromHandle: handle,
|
||||
body: `Empty string test ${uq()}`,
|
||||
isFromMe: false,
|
||||
hasAttachments: false,
|
||||
attachmentCount: 0,
|
||||
sentAt: now,
|
||||
},
|
||||
]);
|
||||
|
||||
const emptySummary: ContactSummaryData = {
|
||||
mostRecently: '',
|
||||
overallSummary: '',
|
||||
recap: '',
|
||||
};
|
||||
const { stub } = makeChatStub(emptySummary);
|
||||
const contact = makeContact(deviceId, handle);
|
||||
|
||||
const result = await generateContactSummary(testDb.pool, contact, stub);
|
||||
|
||||
// The stub returns value directly, bypassing parseSummary.
|
||||
// Verify the coercion path by capturing the parse fn passed to chat.
|
||||
let capturedParse: ((raw: unknown) => ContactSummaryData) | undefined;
|
||||
const capturingStub: ChatFn = async (messages, opts) => {
|
||||
capturedParse = opts.parse;
|
||||
return opts.parse({ mostRecently: '', overallSummary: '', recap: '' });
|
||||
};
|
||||
|
||||
const coercedResult = await generateContactSummary(testDb.pool, contact, capturingStub);
|
||||
|
||||
expect(coercedResult).toEqual({ mostRecently: null, overallSummary: null, recap: null });
|
||||
expect(result).toEqual(emptySummary); // plain stub returns what it's given
|
||||
|
||||
// Verify parse directly
|
||||
if (capturedParse) {
|
||||
expect(capturedParse({ mostRecently: 'x', overallSummary: 'y', recap: 'z' })).toEqual({
|
||||
mostRecently: 'x',
|
||||
overallSummary: 'y',
|
||||
recap: 'z',
|
||||
});
|
||||
expect(() => capturedParse!({ missing: true })).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue