From 4260e4dfc0d17ce5931d7e71c1793eb57cdb1935 Mon Sep 17 00:00:00 2001 From: autocommit Date: Sun, 12 Apr 2026 19:01:17 -0700 Subject: [PATCH] =?UTF-8?q?feat(relationship-worker):=20=E2=9C=A8=20Implem?= =?UTF-8?q?ent=20prompt=20templates=20and=20schema=20validation=20rules=20?= =?UTF-8?q?for=20relationship=20data=20processing=20with=20unit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../relationship-worker/src/prompts.test.ts | 396 ++++++++++++++++ services/relationship-worker/src/prompts.ts | 327 +++++++++++++ .../relationship-worker/src/schemas.test.ts | 438 ++++++++++++++++++ services/relationship-worker/src/schemas.ts | 261 +++++++++++ 4 files changed, 1422 insertions(+) create mode 100644 services/relationship-worker/src/prompts.test.ts create mode 100644 services/relationship-worker/src/prompts.ts create mode 100644 services/relationship-worker/src/schemas.test.ts create mode 100644 services/relationship-worker/src/schemas.ts diff --git a/services/relationship-worker/src/prompts.test.ts b/services/relationship-worker/src/prompts.test.ts new file mode 100644 index 0000000..3ea5ab1 --- /dev/null +++ b/services/relationship-worker/src/prompts.test.ts @@ -0,0 +1,396 @@ +import { describe, expect, it } from 'vitest'; +import { + buildSegmentationPrompt, + buildSummaryPrompt, + buildRelationshipPrompt, + buildNameExtractionPrompt, +} from './prompts'; +import type { MessageRow, TopicRow, GlossaryRow, NameHistoryRow } from './db'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function makeMessage(overrides: Partial = {}): MessageRow { + return { + id: '550e8400-e29b-41d4-a716-446655440000', + conversationId: 'conv-1', + text: 'Hello there', + sentAt: new Date('2026-03-01T10:00:00Z'), + direction: 'incoming', + senderDisplayName: 'Alice', + messageTypes: [], + isAudioMessage: false, + ...overrides, + }; +} + +function makeTopic(overrides: Partial = {}): TopicRow { + return { + id: 'topic-uuid-001', + contactId: 'contact-1', + conversationId: 'conv-1', + kind: 'booking', + label: 'Booking inquiry', + phase: 'active', + summary: 'Discussing availability.', + summaryVersion: 1, + ...overrides, + }; +} + +function makeGlossaryRow(overrides: Partial = {}): GlossaryRow { + return { + emoji: 'πŸ’΅', + meaning: 'financial / paying client', + usageCount: 5, + ...overrides, + }; +} + +function makeNameHistoryRow(overrides: Partial = {}): NameHistoryRow { + return { + id: 'nh-1', + contactId: 'contact-1', + name: 'Oli', + direction: 'contact_self', + firstSeenAt: new Date('2026-01-10T09:00:00Z'), + lastSeenAt: new Date('2026-01-15T12:00:00Z'), + context: 'introduced himself as Oli', + source: 'llm', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// buildNameExtractionPrompt +// --------------------------------------------------------------------------- + +describe('buildNameExtractionPrompt', () => { + it('includes contactDisplayName in systemPrompt', () => { + const { systemPrompt } = buildNameExtractionPrompt([], 'Alice Smith'); + expect(systemPrompt).toContain('Alice Smith'); + }); + + it('includes contactDisplayName in userContent', () => { + const { userContent } = buildNameExtractionPrompt([], 'Alice Smith'); + expect(userContent).toContain('Alice Smith'); + }); + + it('includes message IDs in userContent', () => { + const msg = makeMessage({ id: '550e8400-e29b-41d4-a716-446655440000' }); + const { userContent } = buildNameExtractionPrompt([msg], 'Alice'); + expect(userContent).toContain('550e8400-e29b-41d4-a716-446655440000'); + }); + + it('includes message text in userContent', () => { + const msg = makeMessage({ text: 'Hey Quinn, it is me Oliver' }); + const { userContent } = buildNameExtractionPrompt([msg], 'Alice'); + expect(userContent).toContain('Hey Quinn, it is me Oliver'); + }); + + it('reports message count in userContent', () => { + const msgs = [makeMessage(), makeMessage()]; + const { userContent } = buildNameExtractionPrompt(msgs, 'Alice'); + expect(userContent).toContain('2 total'); + }); + + it('labels incoming messages as THEM', () => { + const msg = makeMessage({ direction: 'incoming' }); + const { userContent } = buildNameExtractionPrompt([msg], 'Alice'); + expect(userContent).toContain('THEM'); + }); + + it('labels outgoing messages as QUINN', () => { + const msg = makeMessage({ direction: 'outgoing' }); + const { userContent } = buildNameExtractionPrompt([msg], 'Alice'); + expect(userContent).toContain('QUINN'); + }); + + it('notes voice messages', () => { + const msg = makeMessage({ isAudioMessage: true }); + const { userContent } = buildNameExtractionPrompt([msg], 'Alice'); + expect(userContent).toContain('voice message'); + }); + + it('includes quinn aliases in systemPrompt when provided', () => { + const aliases = [ + { name: 'Quinn', context: 'personal', isCurrent: true }, + { name: 'Victoria', context: 'escorting', isCurrent: true }, + ]; + const { systemPrompt } = buildNameExtractionPrompt([], 'Alice', aliases); + expect(systemPrompt).toContain('Quinn'); + expect(systemPrompt).toContain('Victoria'); + expect(systemPrompt).toContain('quinn_self'); + }); + + it('includes quinn aliases in userContent summary line', () => { + const aliases = [{ name: 'Quinn', context: null, isCurrent: true }]; + const { userContent } = buildNameExtractionPrompt([], 'Alice', aliases); + expect(userContent).toContain("Quinn's aliases: Quinn"); + }); + + it('omits alias section from systemPrompt when no aliases provided', () => { + const { systemPrompt } = buildNameExtractionPrompt([], 'Alice', []); + expect(systemPrompt).not.toContain("Quinn's known names/aliases"); + }); + + it('omits alias line from userContent when no aliases provided', () => { + const { userContent } = buildNameExtractionPrompt([], 'Alice', []); + expect(userContent).not.toContain("Quinn's aliases:"); + }); + + it('marks retired aliases correctly in systemPrompt', () => { + const aliases = [{ name: 'Natalie', context: 'old alias', isCurrent: false }]; + const { systemPrompt } = buildNameExtractionPrompt([], 'Alice', aliases); + expect(systemPrompt).toContain('(retired)'); + expect(systemPrompt).toContain('Natalie'); + }); +}); + +// --------------------------------------------------------------------------- +// buildSegmentationPrompt +// --------------------------------------------------------------------------- + +describe('buildSegmentationPrompt', () => { + it('includes message UUIDs in userContent', () => { + const msg = makeMessage({ id: '550e8400-e29b-41d4-a716-446655440000' }); + const { userContent } = buildSegmentationPrompt([msg], [], new Map()); + expect(userContent).toContain('550e8400-e29b-41d4-a716-446655440000'); + }); + + it('includes existing topic IDs in userContent', () => { + const topic = makeTopic({ id: 'topic-uuid-001' }); + const { userContent } = buildSegmentationPrompt([], [topic], new Map()); + expect(userContent).toContain('topic-uuid-001'); + }); + + it('includes existing topic labels in userContent', () => { + const topic = makeTopic({ label: 'Booking inquiry' }); + const { userContent } = buildSegmentationPrompt([], [topic], new Map()); + expect(userContent).toContain('Booking inquiry'); + }); + + it('includes LOCKED section when manual assignments present', () => { + const assignments = new Map([['msg-1', 'topic-1']]); + const { systemPrompt } = buildSegmentationPrompt([], [], assignments); + expect(systemPrompt).toContain('LOCKED'); + expect(systemPrompt).toContain('msg-1'); + expect(systemPrompt).toContain('topic-1'); + }); + + it('omits LOCKED section when no manual assignments', () => { + const { systemPrompt } = buildSegmentationPrompt([], [], new Map()); + expect(systemPrompt).not.toContain('LOCKED ASSIGNMENTS'); + }); + + it('includes message count in userContent', () => { + const msgs = [makeMessage(), makeMessage(), makeMessage()]; + const { userContent } = buildSegmentationPrompt(msgs, [], new Map()); + expect(userContent).toContain('3 total'); + }); + + it('omits existing topics section when no topics provided', () => { + const { userContent } = buildSegmentationPrompt([makeMessage()], [], new Map()); + expect(userContent).not.toContain('Existing topics'); + }); + + it('includes existing topics section when topics provided', () => { + const { userContent } = buildSegmentationPrompt([], [makeTopic()], new Map()); + expect(userContent).toContain('Existing topics'); + }); + + it('includes topic summary in userContent when topic has a summary', () => { + const topic = makeTopic({ summary: 'Discussing availability.' }); + const { userContent } = buildSegmentationPrompt([], [topic], new Map()); + expect(userContent).toContain('Discussing availability.'); + }); + + it('labels incoming as THEM and outgoing as QUINN', () => { + const incoming = makeMessage({ direction: 'incoming', text: 'incoming text' }); + const outgoing = makeMessage({ direction: 'outgoing', text: 'outgoing text', id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' }); + const { userContent } = buildSegmentationPrompt([incoming, outgoing], [], new Map()); + expect(userContent).toContain('THEM'); + expect(userContent).toContain('QUINN'); + }); +}); + +// --------------------------------------------------------------------------- +// buildSummaryPrompt +// --------------------------------------------------------------------------- + +describe('buildSummaryPrompt', () => { + it('includes topic label in userContent', () => { + const { userContent } = buildSummaryPrompt('Booking inquiry', 'booking', [], null); + expect(userContent).toContain('Booking inquiry'); + }); + + it('includes previous summary when provided', () => { + const { userContent } = buildSummaryPrompt('Topic', 'other', [], 'Prior context about booking.'); + expect(userContent).toContain('Prior context about booking.'); + }); + + it('omits previous summary section when null', () => { + const { userContent } = buildSummaryPrompt('Topic', 'other', [], null); + expect(userContent).not.toContain('Previous summary'); + }); + + it('includes message_sentiments in systemPrompt schema reference', () => { + const { systemPrompt } = buildSummaryPrompt('Topic', 'other', [], null); + expect(systemPrompt).toContain('message_sentiments'); + }); + + it('includes message count in userContent', () => { + const msgs = [makeMessage(), makeMessage()]; + const { userContent } = buildSummaryPrompt('Topic', 'other', msgs, null); + expect(userContent).toContain('2 total'); + }); + + it('includes topic kind in userContent', () => { + const { userContent } = buildSummaryPrompt('Topic', 'financial', [], null); + expect(userContent).toContain('financial'); + }); + + it('includes message IDs in userContent', () => { + const msg = makeMessage({ id: '550e8400-e29b-41d4-a716-446655440000' }); + const { userContent } = buildSummaryPrompt('Topic', 'other', [msg], null); + expect(userContent).toContain('550e8400-e29b-41d4-a716-446655440000'); + }); +}); + +// --------------------------------------------------------------------------- +// buildRelationshipPrompt +// --------------------------------------------------------------------------- + +describe('buildRelationshipPrompt', () => { + const baseArgs: Parameters = [ + 'Alice', + [{ label: 'Booking', kind: 'booking', phase: 'active', summary: 'She books regularly.' }], + [makeGlossaryRow()], + [], + [{ displayName: 'Bob' }, { displayName: 'Carol' }], + [], + ]; + + it('includes glossary entries in systemPrompt', () => { + const { systemPrompt } = buildRelationshipPrompt(...baseArgs); + expect(systemPrompt).toContain('πŸ’΅'); + expect(systemPrompt).toContain('financial / paying client'); + }); + + it('includes contact roster in systemPrompt', () => { + const { systemPrompt } = buildRelationshipPrompt(...baseArgs); + expect(systemPrompt).toContain('Bob'); + expect(systemPrompt).toContain('Carol'); + }); + + it('includes contactName in systemPrompt for name-transition rule', () => { + const { systemPrompt } = buildRelationshipPrompt(...baseArgs); + expect(systemPrompt).toContain('Alice'); + }); + + it('includes topic summaries in userContent', () => { + const { userContent } = buildRelationshipPrompt(...baseArgs); + expect(userContent).toContain('She books regularly.'); + expect(userContent).toContain('Booking'); + }); + + it('includes name history in userContent when provided', () => { + const nameHistory = [makeNameHistoryRow({ name: 'Oli', direction: 'contact_self' })]; + const args: Parameters = [ + 'Alice', + baseArgs[1], + baseArgs[2], + baseArgs[3], + baseArgs[4], + nameHistory, + ]; + const { userContent } = buildRelationshipPrompt(...args); + expect(userContent).toContain('Name history'); + expect(userContent).toContain('Oli'); + expect(userContent).toContain('contact_self'); + }); + + it('omits name history section when empty', () => { + const { userContent } = buildRelationshipPrompt(...baseArgs); + expect(userContent).not.toContain('Name history'); + }); + + it('includes few-shot examples in systemPrompt when provided', () => { + const fewShot = [ + { + emoji: 'πŸ’΅', + meaning: 'paying client', + contacts: [{ name: 'TestContact', summary: 'Long-term client.' }], + }, + ]; + const args: Parameters = [ + 'Alice', + baseArgs[1], + baseArgs[2], + fewShot, + baseArgs[4], + [], + ]; + const { systemPrompt } = buildRelationshipPrompt(...args); + expect(systemPrompt).toContain('TestContact'); + expect(systemPrompt).toContain('Long-term client.'); + }); + + it('omits few-shot section when no examples provided', () => { + const { systemPrompt } = buildRelationshipPrompt(...baseArgs); + expect(systemPrompt).not.toContain('Examples of how Quinn uses'); + }); + + it('includes glossary emoji with usage count', () => { + const { systemPrompt } = buildRelationshipPrompt(...baseArgs); + expect(systemPrompt).toContain('5Γ—'); + }); + + it('filters out glossary entries with zero usage and no meaning', () => { + const args: Parameters = [ + 'Alice', + baseArgs[1], + [makeGlossaryRow({ emoji: 'πŸ”‡', meaning: null, usageCount: 0 })], + [], + baseArgs[4], + [], + ]; + const { systemPrompt } = buildRelationshipPrompt(...args); + expect(systemPrompt).not.toContain('πŸ”‡'); + }); + + it('includes name history context when present', () => { + const nameHistory = [makeNameHistoryRow({ context: 'introduced himself as Oli' })]; + const args: Parameters = [ + 'Alice', + baseArgs[1], + baseArgs[2], + baseArgs[3], + baseArgs[4], + nameHistory, + ]; + const { userContent } = buildRelationshipPrompt(...args); + expect(userContent).toContain('introduced himself as Oli'); + }); + + it('includes date range for name history entries', () => { + const nameHistory = [ + makeNameHistoryRow({ + firstSeenAt: new Date('2026-01-10T00:00:00Z'), + lastSeenAt: new Date('2026-01-20T00:00:00Z'), + }), + ]; + const args: Parameters = [ + 'Alice', + baseArgs[1], + baseArgs[2], + baseArgs[3], + baseArgs[4], + nameHistory, + ]; + const { userContent } = buildRelationshipPrompt(...args); + expect(userContent).toContain('2026-01-10'); + expect(userContent).toContain('2026-01-20'); + }); +}); diff --git a/services/relationship-worker/src/prompts.ts b/services/relationship-worker/src/prompts.ts new file mode 100644 index 0000000..80f38a4 --- /dev/null +++ b/services/relationship-worker/src/prompts.ts @@ -0,0 +1,327 @@ +/** + * LLM prompt builders for the three pipeline stages. + * + * Each builder produces a {systemPrompt, messages} pair for chatJson. + * Prompts include explicit JSON examples β€” model-boss chatJson is + * "prompted JSON" (not grammar-constrained), so inline examples are + * critical for output reliability. + * + * GROUNDING RULE (ported from @life assistant-trainer): + * Every prompt includes: "ONLY reference facts from the messages provided. + * Do NOT invent events, relationships, or details. If you cannot determine + * something, say so explicitly β€” never guess." + */ + +import type { GlossaryRow, MessageRow, NameHistoryRow, TopicRow } from './db'; + +function formatMessage(msg: MessageRow): string { + const dir = msg.direction === 'incoming' ? 'THEM' : 'QUINN'; + const audio = msg.isAudioMessage ? ' [voice message β€” no transcript available]' : ''; + const types = msg.messageTypes.length > 0 ? ` [classifier: ${msg.messageTypes.join(', ')}]` : ''; + const text = msg.text?.trim() || '[no text content]'; + return `[${msg.id}] ${msg.sentAt.toISOString().slice(0, 16)} ${dir}${types}${audio}: ${text}`; +} + +function formatTopic(topic: TopicRow): string { + return `- TOPIC "${topic.label}" (id=${topic.id}, kind=${topic.kind}, phase=${topic.phase})${ + topic.summary ? `\n Summary: ${topic.summary}` : '' + }`; +} + +// ---------- Stage 0: Name Extraction ---------- + +export function buildNameExtractionPrompt( + messages: MessageRow[], + contactDisplayName: string, + quinnAliases: Array<{ name: string; context: string | null; isCurrent: boolean }> = [], +): { systemPrompt: string; userContent: string } { + const aliasSection = quinnAliases.length > 0 + ? `Quinn's known names/aliases (classify any of these as "quinn_self" when found in messages): +${quinnAliases.map((a) => + `- "${a.name}"${a.isCurrent ? ' (current)' : ' (retired)'}${a.context ? ` [${a.context}]` : ''}` + ).join('\n')} + +` + : ''; + + const systemPrompt = `You are a name analyst. Scan these messages between Quinn and one of her contacts for how each party identifies themselves and each other. Extract every distinct name, nickname, or form of address used. + +${aliasSection}The contact's current display name in Quinn's phone is: "${contactDisplayName}" + +For each name found, note: +- The name or nickname +- Direction: "contact_self" (how the contact identifies), "quinn_self" (how Quinn identifies to this contact), "quinn_for_contact" (how Quinn refers to the contact), "contact_for_quinn" (what the contact calls Quinn) +- The first and last message IDs where this name appears +- Brief context (e.g., "introduced himself as", "signed off as", "affectionate nickname") + +DIRECTION RULES: +- "quinn_self": a name from Quinn's alias list that appears in a QUINN: message (Quinn introducing or signing off as that name) +- "contact_for_quinn": a name from Quinn's alias list that appears in a THEM: message (the contact addressing or referring to Quinn by that name) +- "quinn_for_contact": a name Quinn uses to address or refer to the contact in QUINN: messages +- "contact_self": a name the contact uses to identify themselves in THEM: messages (e.g. "this is Jas", "I'm Alex") + +Use the QUINN:/THEM: prefix on each message to determine speaker. Never classify a THEM: alias use as "quinn_self" β€” the contact addressing Quinn by her alias is "contact_for_quinn". + +If the contact uses a DIFFERENT name than their current display name, this is significant β€” it may indicate they initially used a fake name or nickname. + +ONLY reference facts from the messages provided. Do NOT invent names or patterns that do not appear. + +Output ONLY JSON: +{ + "names": [ + {"name": "Oli", "direction": "contact_self", "firstMessageId": "uuid", "lastMessageId": "uuid", "context": "introduced himself as Oli"}, + {"name": "Victoria", "direction": "quinn_self", "firstMessageId": "uuid", "lastMessageId": "uuid", "context": "Quinn signed off as Victoria"} + ] +} + +If no names are explicitly used in the messages, output: {"names": []}`; + + const aliasLine = quinnAliases.length > 0 + ? `Quinn's aliases: ${quinnAliases.map((a) => a.name).join(', ')}\n` + : ''; + + const userContent = `${aliasLine}Contact display name: "${contactDisplayName}" + +Messages (chronological, ${messages.length} total): + +${messages.map((m) => formatMessage(m)).join('\n')} + +Extract name usage patterns. Output ONLY the JSON object β€” no markdown, no explanation.`; + + return { systemPrompt, userContent }; +} + +// ---------- Stage 1: Segmentation ---------- + +export function buildSegmentationPrompt( + messages: MessageRow[], + existingTopics: TopicRow[], + manualAssignments: Map, +): { systemPrompt: string; userContent: string } { + const manualLines: string[] = []; + for (const [msgId, topicId] of manualAssignments) { + manualLines.push(` Message ${msgId} β†’ Topic ${topicId} (LOCKED β€” do not reassign)`); + } + + const systemPrompt = `You are a conversation analyst. Your job is to segment a chronological list of messages between Quinn and one of her contacts into parallel conversation topics. + +RULES: +- Each topic is a coherent conversational thread (e.g., "Booking inquiry - March 15", "Personal catch-up", "Financial discussion about payment"). +- A single message can belong to MULTIPLE topics if it bridges two conversations (e.g., "hey about that booking, also how's your cat?" belongs to both booking and personal). +- Assign a confidence score (0.0–1.0) for each message–topic assignment. +- If existing topics are provided, reuse their IDs when the new messages continue those threads. Create new topics only when a genuinely new conversational thread starts. +- Messages marked LOCKED below were manually assigned by Quinn and MUST NOT be reassigned. Respect these as fixed constraints. +- The "kind" field should be one of: booking, personal, financial, followup, meta, professional, or other. +- ONLY reference facts from the messages provided. Do NOT invent topics or messages. + +${manualLines.length > 0 ? `LOCKED ASSIGNMENTS (do not reassign these messages):\n${manualLines.join('\n')}\n` : ''} +Output ONLY a JSON object matching this exact schema: + +{ + "topics": [ + { + "id": "existing-topic-uuid-or-null", + "label": "Short descriptive label", + "kind": "booking", + "messages": [ + {"messageId": "uuid-of-message", "confidence": 0.95}, + {"messageId": "uuid-of-another", "confidence": 0.7} + ] + } + ] +} + +Example output for a conversation with 3 messages that split into 2 topics: +{ + "topics": [ + { + "id": null, + "label": "Booking inquiry - weekend availability", + "kind": "booking", + "messages": [ + {"messageId": "msg-001", "confidence": 1.0}, + {"messageId": "msg-003", "confidence": 0.8} + ] + }, + { + "id": null, + "label": "Personal - catching up about life", + "kind": "personal", + "messages": [ + {"messageId": "msg-002", "confidence": 1.0}, + {"messageId": "msg-003", "confidence": 0.6} + ] + } + ] +} + +Note: msg-003 appears in BOTH topics with different confidence β€” this is correct when a message bridges two conversations.`; + + const topicSection = existingTopics.length > 0 + ? `\nExisting topics for this contact (reuse IDs when messages continue these threads):\n${existingTopics.map(formatTopic).join('\n')}\n` + : ''; + + const userContent = `${topicSection} +Messages (chronological, ${messages.length} total): + +${messages.map((m) => formatMessage(m)).join('\n')} + +Segment these messages into topics. Output ONLY the JSON object β€” no markdown, no explanation.`; + + return { systemPrompt, userContent }; +} + +// ---------- Stage 2: Topic Summary ---------- + +export function buildSummaryPrompt( + topicLabel: string, + topicKind: string, + messages: MessageRow[], + previousSummary: string | null, +): { systemPrompt: string; userContent: string } { + const systemPrompt = `You are a conversation summarizer. You will receive messages belonging to a single conversational topic between Quinn and one of her contacts. Produce a concise narrative summary of this topic. + +RULES: +- Summarize what happened, what was discussed, what the outcome was (or if it's still pending). +- Note the emotional tone if relevant (warm, tense, transactional, flirty, etc.). +- If a previous summary is provided, EXTEND it with new information rather than rewriting from scratch. Preserve important facts from the previous summary. +- Determine the topic kind (booking, personal, financial, followup, meta, professional, other). +- Determine the topic phase: "active" (ongoing), "stalled" (no recent messages, may resume), "closed" (concluded). +- Rate each message's emotional valence: -1.0 = hostile or cold, 0.0 = neutral/transactional, +1.0 = warm, affectionate, or joyful. Focus on messages with clear emotional signal β€” you do not need to rate purely informational or empty messages. Aim to cover the most emotionally significant messages (up to 20). +- ONLY reference facts from the messages. Do NOT invent details. + +Output ONLY a JSON object matching this schema: + +{ + "summary": "Narrative summary of the conversation topic", + "kind": "booking", + "phase": "active", + "message_sentiments": [ + {"messageId": "uuid-of-message", "sentiment": 0.8}, + {"messageId": "uuid-of-another", "sentiment": -0.3} + ] +} + +Keep message_sentiments short (rate only emotionally significant messages, skip purely transactional or empty ones). The array can have 0 entries if no messages have clear emotional valence.`; + + const prevSection = previousSummary + ? `Previous summary (extend, don't rewrite):\n${previousSummary}\n\n` + : ''; + + const userContent = `Topic: "${topicLabel}" (kind: ${topicKind}) + +${prevSection}Messages in this topic (${messages.length} total): + +${messages.map((m) => formatMessage(m)).join('\n')} + +Summarize this topic. Output ONLY the JSON object β€” no markdown, no explanation.`; + + return { systemPrompt, userContent }; +} + +// ---------- Stage 3: Relationship Synthesis ---------- + +export function buildRelationshipPrompt( + contactName: string, + topicSummaries: Array<{ label: string; kind: string; phase: string; summary: string }>, + glossary: GlossaryRow[], + fewShotExamples: Array<{ emoji: string; meaning: string | null; contacts: Array<{ name: string; summary: string | null }> }>, + contactRoster: Array<{ displayName: string }>, + nameHistory: NameHistoryRow[], +): { systemPrompt: string; userContent: string } { + const glossarySection = glossary + .filter((g) => g.usageCount > 0 || g.meaning) + .map((g) => ` ${g.emoji} β€” ${g.meaning ?? '(meaning not set)'} (used ${g.usageCount}Γ— across contacts)`) + .join('\n'); + + const fewShotSection = fewShotExamples + .filter((ex) => ex.contacts.length > 0) + .map((ex) => { + const contactLines = ex.contacts + .map((c) => ` - ${c.name}: ${c.summary ?? '(no summary yet)'}`) + .join('\n'); + return ` ${ex.emoji} (${ex.meaning ?? 'unknown meaning'}):\n${contactLines}`; + }) + .join('\n'); + + const rosterNames = contactRoster.map((c) => c.displayName).join(', '); + + const nameHistorySection = nameHistory.length > 0 + ? nameHistory + .map((n) => { + const first = n.firstSeenAt.toISOString().slice(0, 10); + const last = n.lastSeenAt.toISOString().slice(0, 10); + const range = first === last ? first : `${first} – ${last}`; + return `- "${n.name}" (${n.direction}, ${range})${n.context ? `: ${n.context}` : ''}`; + }) + .join('\n') + : null; + + const systemPrompt = `You are a relationship analyst for Quinn's personal messaging system. Given a set of conversation topic summaries about a specific contact, synthesize a comprehensive relationship model. + +RULES: +- Determine the relationship "kind": client, friend, peer, family, romantic, professional, ambiguous, or other. +- CLIENT DETECTION IS CRITICAL: Quinn is an escort. If the FIRST message from the contact mentions a platform (Tryst, Seeking, Seeking Arrangement), requests a booking/meeting/session, discusses rates/hours/availability, or uses transactional language ("I'd like to see you", "are you available", "how much"), the kind MUST be "client" β€” even if subsequent messages become warm or friendly. A client who sends warm follow-up messages is still a client. Only classify as "friend" or "peer" if there is NO transactional/booking origin to the relationship. +- Determine the current "phase": fresh (new relationship), active (ongoing engagement), fading (decreasing contact), dormant (long silence), ended (relationship concluded). +- Write a narrative summary of the relationship covering: how they know each other, what they typically communicate about, current state of the relationship, and any notable dynamics or patterns. +- Extract major events β€” one entry per distinct occurrence. Do NOT collapse multiple events of the same kind into one summary entry. If there were three bookings, generate three booking events. Event kinds: introduction (first contact), booking (each individual session/appointment), duo (session involving another escort), rupture (conflict, bad experience, breach of trust), milestone (meaningful trust moment, shift in dynamic), reconciliation (reconnecting after a rupture or long silence), identity (contact reveals real name), other. Each event needs a date (YYYY-MM-DD), kind, and description. +- If an event involves another person Quinn knows, include their name in "related_contact_name" β€” but ONLY if that person appears in Quinn's contact roster (listed below). Do not invent names. +- Suggest emojis from Quinn's personal glossary (listed below) that fit this contact. ONLY suggest emojis from the glossary β€” never invent new ones. +- EMOJI ORDERING IS CRITICAL. Index 0 = the single most defining thing about this contact to Quinn RIGHT NOW. Priority depends on kind: + - For kind="client": index 0 should be the client/transactional emoji (πŸ’΅ paid client). Wealth indicators (πŸ’°) and other traits come after. Do NOT put πŸ«‚ (friend) at index 0 for a client β€” warmth doesn't change the transactional nature. + - For kind="friend" or "peer": index 0 should be the personal bond emoji (πŸ«‚ current friend, 🌸 new friend). Professional labels (🌹 escort peer) go after the bond. + - For kind="romantic": index 0 should be the emotional state (πŸ’” heartbreak, πŸ‘» ghost if ended). + - General rule: (1) relationship-kind-defining emoji first; (2) current state (ghost, fading) second; (3) traits (πŸ’° wealthy, πŸ‘” professional) last. + Concrete example: a FRIEND who is also an escort peer β†’ ['πŸ«‚', '🌹']. A CLIENT who is warm β†’ ['πŸ’΅', 'πŸ’°']. NEVER ['πŸ«‚'] for a client. +- NAME-TRANSITION DETECTION (contact only, never Quinn): The contact's current display name in Quinn's phone is "${contactName}". If the name history (provided below) shows the CONTACT used a DIFFERENT name earlier under "contact_self" (e.g., they first said "I'm Oli" but their display name is now "Oliver"), extract this as a relationship event with kind "identity" β€” it marks a trust milestone where the CONTACT revealed their real name. Use the date range from the name history as the event date. +- NEVER generate events about Quinn's own names or aliases. Quinn uses professional aliases (Quinn, Victoria, Natalie, etc.) with all contacts β€” this is normal and must NOT be described as a reveal, a milestone, or any kind of event. The name history rows with direction "quinn_self" or "contact_for_quinn" are irrelevant to event extraction. +- ONLY reference facts from the topic summaries and name history provided. Do NOT invent events or details. + +Quinn's emoji glossary (ONLY suggest from this list): +${glossarySection} + +${fewShotSection ? `Examples of how Quinn uses these emojis on other contacts:\n${fewShotSection}\n` : ''} +Quinn's known contacts (for cross-referencing events): ${rosterNames} + +Output ONLY a JSON object matching this schema: + +{ + "kind": "friend", + "phase": "active", + "summary": "Narrative relationship summary...", + "events": [ + { + "at": "2026-02-15", + "kind": "introduction", + "description": "First message β€” Quinn reached out about...", + "related_contact_name": null + }, + { + "at": "2026-03-01", + "kind": "duo", + "description": "Session with Andy β€” duo booking, went smoothly", + "related_contact_name": "Andy" + }, + { + "at": "2026-03-15", + "kind": "rupture", + "description": "No-showed a confirmed booking with no warning", + "related_contact_name": null + } + ], + "suggested_emojis": ["πŸ«‚", "πŸ’΅"] +}`; + + const topicLines = topicSummaries + .map((t) => `- "${t.label}" (${t.kind}, ${t.phase}): ${t.summary}`) + .join('\n'); + + const userContent = `Contact: ${contactName} + +${nameHistorySection ? `Name history for this contact:\n${nameHistorySection}\n\n` : ''}Conversation topic summaries (${topicSummaries.length} topics): +${topicLines} + +Synthesize the relationship model. Output ONLY the JSON object β€” no markdown, no explanation.`; + + return { systemPrompt, userContent }; +} diff --git a/services/relationship-worker/src/schemas.test.ts b/services/relationship-worker/src/schemas.test.ts new file mode 100644 index 0000000..a100825 --- /dev/null +++ b/services/relationship-worker/src/schemas.test.ts @@ -0,0 +1,438 @@ +import { describe, expect, it } from 'vitest'; +import { + parseSegmentation, + parseTopicSummary, + parseRelationshipSynthesis, + parseNameExtraction, +} from './schemas'; + +// ---------- parseSegmentation ---------- + +describe('parseSegmentation', () => { + it('parses valid input', () => { + const result = parseSegmentation({ + topics: [ + { + id: '550e8400-e29b-41d4-a716-446655440000', + label: 'Booking inquiry', + kind: 'booking', + messages: [{ messageId: 'aaa-111', confidence: 0.9 }], + }, + ], + }); + expect(result.topics).toHaveLength(1); + expect(result.topics[0]!.label).toBe('Booking inquiry'); + expect(result.topics[0]!.kind).toBe('booking'); + expect(result.topics[0]!.id).toBe('550e8400-e29b-41d4-a716-446655440000'); + expect(result.topics[0]!.messages[0]!.messageId).toBe('aaa-111'); + expect(result.topics[0]!.messages[0]!.confidence).toBe(0.9); + }); + + it('throws when payload is not an object', () => { + expect(() => parseSegmentation('string')).toThrow('expected object'); + expect(() => parseSegmentation(null)).toThrow('expected object'); + expect(() => parseSegmentation(42)).toThrow('expected object'); + }); + + it('throws when topics is missing or not an array', () => { + expect(() => parseSegmentation({})).toThrow('"topics" must be an array'); + expect(() => parseSegmentation({ topics: 'nope' })).toThrow('"topics" must be an array'); + }); + + it('sets id to null when topic.id is not a string', () => { + const result = parseSegmentation({ + topics: [ + { id: null, label: 'L', kind: 'personal', messages: [] }, + { label: 'L2', kind: 'other', messages: [] }, + ], + }); + expect(result.topics[0]!.id).toBeNull(); + expect(result.topics[1]!.id).toBeNull(); + }); + + it('clamps confidence below 0 to 0', () => { + const result = parseSegmentation({ + topics: [ + { + id: null, + label: 'L', + kind: 'other', + messages: [{ messageId: 'msg-1', confidence: -0.5 }], + }, + ], + }); + expect(result.topics[0]!.messages[0]!.confidence).toBe(0); + }); + + it('clamps confidence above 1 to 1', () => { + const result = parseSegmentation({ + topics: [ + { + id: null, + label: 'L', + kind: 'other', + messages: [{ messageId: 'msg-1', confidence: 2.5 }], + }, + ], + }); + expect(result.topics[0]!.messages[0]!.confidence).toBe(1); + }); + + it('defaults missing confidence to 1.0', () => { + const result = parseSegmentation({ + topics: [ + { + id: null, + label: 'L', + kind: 'other', + messages: [{ messageId: 'msg-1' }], + }, + ], + }); + expect(result.topics[0]!.messages[0]!.confidence).toBe(1); + }); + + it('throws when a topic item is not an object', () => { + expect(() => parseSegmentation({ topics: ['not-an-object'] })).toThrow('topics[0] must be an object'); + }); + + it('throws when topic.label is missing', () => { + expect(() => + parseSegmentation({ topics: [{ id: null, kind: 'other', messages: [] }] }), + ).toThrow('topics[0].label must be a string'); + }); + + it('throws when topic.messages is not an array', () => { + expect(() => + parseSegmentation({ topics: [{ id: null, label: 'L', kind: 'other', messages: 'x' }] }), + ).toThrow('topics[0].messages must be an array'); + }); + + it('throws when a message entry is missing messageId', () => { + expect(() => + parseSegmentation({ + topics: [{ id: null, label: 'L', kind: 'other', messages: [{ confidence: 0.9 }] }], + }), + ).toThrow('topics[0].messages[0].messageId must be a string'); + }); + + it('returns empty topics array for empty input', () => { + const result = parseSegmentation({ topics: [] }); + expect(result.topics).toHaveLength(0); + }); +}); + +// ---------- parseTopicSummary ---------- + +describe('parseTopicSummary', () => { + const validPayload = { + summary: 'Discussed booking for March.', + kind: 'booking', + phase: 'active', + message_sentiments: [ + { messageId: 'msg-001', sentiment: 0.8 }, + { messageId: 'msg-002', sentiment: -0.2 }, + ], + }; + + it('parses valid input', () => { + const result = parseTopicSummary(validPayload); + expect(result.summary).toBe('Discussed booking for March.'); + expect(result.kind).toBe('booking'); + expect(result.phase).toBe('active'); + expect(result.messageSentiments).toHaveLength(2); + expect(result.messageSentiments[0]!.messageId).toBe('msg-001'); + expect(result.messageSentiments[0]!.sentiment).toBe(0.8); + }); + + it('defaults invalid phase to "active"', () => { + const result = parseTopicSummary({ ...validPayload, phase: 'unknown-phase' }); + expect(result.phase).toBe('active'); + }); + + it('accepts all valid phases', () => { + for (const phase of ['active', 'stalled', 'closed'] as const) { + const result = parseTopicSummary({ ...validPayload, phase }); + expect(result.phase).toBe(phase); + } + }); + + it('filters out bad message_sentiments entries', () => { + const result = parseTopicSummary({ + ...validPayload, + message_sentiments: [ + { messageId: 'msg-001', sentiment: 0.5 }, + { sentiment: 0.5 }, // missing messageId + null, // null entry + { messageId: 'msg-003', sentiment: 'not-a-number' }, // non-numeric, fails parseFloat β†’ NaN + 'invalid-entry', // not an object + ], + }); + expect(result.messageSentiments).toHaveLength(1); + expect(result.messageSentiments[0]!.messageId).toBe('msg-001'); + }); + + it('clamps sentiment values to [-1, 1]', () => { + const result = parseTopicSummary({ + ...validPayload, + message_sentiments: [ + { messageId: 'msg-a', sentiment: 1.5 }, + { messageId: 'msg-b', sentiment: -1.5 }, + ], + }); + expect(result.messageSentiments[0]!.sentiment).toBe(1); + expect(result.messageSentiments[1]!.sentiment).toBe(-1); + }); + + it('parses numeric string sentiments', () => { + const result = parseTopicSummary({ + ...validPayload, + message_sentiments: [{ messageId: 'msg-1', sentiment: '0.7' }], + }); + expect(result.messageSentiments[0]!.sentiment).toBe(0.7); + }); + + it('handles empty message_sentiments array', () => { + const result = parseTopicSummary({ ...validPayload, message_sentiments: [] }); + expect(result.messageSentiments).toHaveLength(0); + }); + + it('handles missing message_sentiments (defaults to empty)', () => { + const { message_sentiments: _, ...rest } = validPayload; + const result = parseTopicSummary(rest); + expect(result.messageSentiments).toHaveLength(0); + }); + + it('throws when payload is not an object', () => { + expect(() => parseTopicSummary(null)).toThrow('expected object'); + }); + + it('throws when summary is missing', () => { + const { summary: _, ...rest } = validPayload; + expect(() => parseTopicSummary(rest)).toThrow('"summary" must be a string'); + }); + + it('throws when kind is missing', () => { + const { kind: _, ...rest } = validPayload; + expect(() => parseTopicSummary(rest)).toThrow('"kind" must be a string'); + }); + + it('throws when phase is missing', () => { + const { phase: _, ...rest } = validPayload; + expect(() => parseTopicSummary(rest)).toThrow('"phase" must be a string'); + }); +}); + +// ---------- parseRelationshipSynthesis ---------- + +describe('parseRelationshipSynthesis', () => { + const validPayload = { + kind: 'client', + phase: 'active', + summary: 'A paying client met online.', + events: [ + { at: '2026-01-10', kind: 'introduction', description: 'First contact.', related_contact_name: null }, + ], + suggested_emojis: ['πŸ’΅', '🌹'], + }; + + it('parses valid input', () => { + const result = parseRelationshipSynthesis(validPayload); + expect(result.kind).toBe('client'); + expect(result.phase).toBe('active'); + expect(result.summary).toBe('A paying client met online.'); + expect(result.events).toHaveLength(1); + expect(result.events[0]!.at).toBe('2026-01-10'); + expect(result.events[0]!.relatedContactName).toBeNull(); + expect(result.suggestedEmojis).toEqual(['πŸ’΅', '🌹']); + }); + + it('takes first element when kind is an array', () => { + const result = parseRelationshipSynthesis({ ...validPayload, kind: ['friend', 'professional'] }); + expect(result.kind).toBe('friend'); + }); + + it('takes first element when phase is an array', () => { + const result = parseRelationshipSynthesis({ ...validPayload, phase: ['fading', 'active'] }); + expect(result.phase).toBe('fading'); + }); + + it('uses "ambiguous" when kind array is empty', () => { + const result = parseRelationshipSynthesis({ ...validPayload, kind: [] }); + expect(result.kind).toBe('ambiguous'); + }); + + it('maps related_contact_name to relatedContactName', () => { + const result = parseRelationshipSynthesis({ + ...validPayload, + events: [ + { at: '2026-02-01', kind: 'milestone', description: 'Met in person.', related_contact_name: 'Alice' }, + ], + }); + expect(result.events[0]!.relatedContactName).toBe('Alice'); + }); + + it('defaults relatedContactName to null when field absent', () => { + const result = parseRelationshipSynthesis({ + ...validPayload, + events: [{ at: '2026-02-01', kind: 'other', description: 'Something.' }], + }); + expect(result.events[0]!.relatedContactName).toBeNull(); + }); + + it('filters non-string values from suggested_emojis', () => { + const result = parseRelationshipSynthesis({ + ...validPayload, + suggested_emojis: ['πŸ’΅', 42, null, '🌹', true], + }); + expect(result.suggestedEmojis).toEqual(['πŸ’΅', '🌹']); + }); + + it('returns empty suggestedEmojis when field is missing', () => { + const { suggested_emojis: _, ...rest } = validPayload; + const result = parseRelationshipSynthesis(rest); + expect(result.suggestedEmojis).toHaveLength(0); + }); + + it('returns empty events array when field is missing', () => { + const { events: _, ...rest } = validPayload; + const result = parseRelationshipSynthesis(rest); + expect(result.events).toHaveLength(0); + }); + + it('throws when payload is not an object', () => { + expect(() => parseRelationshipSynthesis(null)).toThrow('expected object'); + expect(() => parseRelationshipSynthesis('string')).toThrow('expected object'); + }); + + it('throws when an event item is not an object', () => { + expect(() => + parseRelationshipSynthesis({ ...validPayload, events: ['bad'] }), + ).toThrow('events[0] must be an object'); + }); + + it('throws when event.at is missing', () => { + expect(() => + parseRelationshipSynthesis({ + ...validPayload, + events: [{ kind: 'other', description: 'No date.' }], + }), + ).toThrow('events[0].at must be a string'); + }); + + it('throws when event.kind is missing', () => { + expect(() => + parseRelationshipSynthesis({ + ...validPayload, + events: [{ at: '2026-01-01', description: 'No kind.' }], + }), + ).toThrow('events[0].kind must be a string'); + }); + + it('throws when event.description is missing', () => { + expect(() => + parseRelationshipSynthesis({ + ...validPayload, + events: [{ at: '2026-01-01', kind: 'other' }], + }), + ).toThrow('events[0].description must be a string'); + }); + + it('converts non-string kind to string via String()', () => { + const result = parseRelationshipSynthesis({ ...validPayload, kind: 123 }); + expect(result.kind).toBe('123'); + }); +}); + +// ---------- parseNameExtraction ---------- + +describe('parseNameExtraction', () => { + const validPayload = { + names: [ + { + name: 'Oli', + direction: 'contact_self', + firstMessageId: 'aaa-111', + lastMessageId: 'bbb-222', + context: 'introduced himself as Oli', + }, + ], + }; + + it('parses valid input', () => { + const result = parseNameExtraction(validPayload); + expect(result.names).toHaveLength(1); + expect(result.names[0]!.name).toBe('Oli'); + expect(result.names[0]!.direction).toBe('contact_self'); + expect(result.names[0]!.context).toBe('introduced himself as Oli'); + }); + + it('accepts all valid directions', () => { + const directions = ['contact_self', 'quinn_self', 'quinn_for_contact', 'contact_for_quinn'] as const; + for (const direction of directions) { + const result = parseNameExtraction({ + names: [{ name: 'X', direction, firstMessageId: 'a', lastMessageId: 'b', context: '' }], + }); + expect(result.names[0]!.direction).toBe(direction); + } + }); + + it('filters entries with invalid direction', () => { + const result = parseNameExtraction({ + names: [ + { name: 'Oli', direction: 'invalid-dir', firstMessageId: 'a', lastMessageId: 'b' }, + ], + }); + expect(result.names).toHaveLength(0); + }); + + it('filters entries with empty name', () => { + const result = parseNameExtraction({ + names: [ + { name: ' ', direction: 'contact_self', firstMessageId: 'a', lastMessageId: 'b' }, + ], + }); + expect(result.names).toHaveLength(0); + }); + + it('filters entries with non-string firstMessageId', () => { + const result = parseNameExtraction({ + names: [ + { name: 'Oli', direction: 'contact_self', firstMessageId: null, lastMessageId: 'b' }, + ], + }); + expect(result.names).toHaveLength(0); + }); + + it('filters null entries', () => { + const result = parseNameExtraction({ names: [null, undefined] }); + expect(result.names).toHaveLength(0); + }); + + it('defaults context to empty string when missing', () => { + const result = parseNameExtraction({ + names: [ + { name: 'Oli', direction: 'contact_self', firstMessageId: 'a', lastMessageId: 'b' }, + ], + }); + expect(result.names[0]!.context).toBe(''); + }); + + it('trims whitespace from name', () => { + const result = parseNameExtraction({ + names: [ + { name: ' Victoria ', direction: 'quinn_self', firstMessageId: 'a', lastMessageId: 'b' }, + ], + }); + expect(result.names[0]!.name).toBe('Victoria'); + }); + + it('handles missing names field gracefully', () => { + const result = parseNameExtraction({}); + expect(result.names).toHaveLength(0); + }); + + it('throws when payload is not an object', () => { + expect(() => parseNameExtraction(null)).toThrow('expected object'); + }); +}); diff --git a/services/relationship-worker/src/schemas.ts b/services/relationship-worker/src/schemas.ts new file mode 100644 index 0000000..27bf888 --- /dev/null +++ b/services/relationship-worker/src/schemas.ts @@ -0,0 +1,261 @@ +/** + * JSON schemas + parse validators for the three LLM pipeline stages. + * + * These are passed to model-boss chatJson `schema` param (for prompt injection + * and logging) and the `parse` functions are the actual runtime validators + * that narrow `unknown` β†’ typed output. If `parse` throws, the pipeline + * retries once with a reinforced prompt. + */ + +// ---------- Stage 1: Segmentation ---------- + +export interface SegmentationOutput { + topics: Array<{ + id: string | null; + label: string; + kind: string; + messages: Array<{ messageId: string; confidence: number }>; + }>; +} + +export const SEGMENTATION_SCHEMA: Record = { + type: 'object', + required: ['topics'], + properties: { + topics: { + type: 'array', + items: { + type: 'object', + required: ['label', 'kind', 'messages'], + properties: { + id: { type: ['string', 'null'] }, + label: { type: 'string' }, + kind: { type: 'string' }, + messages: { + type: 'array', + items: { + type: 'object', + required: ['messageId', 'confidence'], + properties: { + messageId: { type: 'string' }, + confidence: { type: 'number', minimum: 0, maximum: 1 }, + }, + }, + }, + }, + }, + }, + }, + additionalProperties: false, +}; + +export function parseSegmentation(payload: unknown): SegmentationOutput { + if (!payload || typeof payload !== 'object') throw new Error('expected object'); + const obj = payload as Record; + if (!Array.isArray(obj.topics)) throw new Error('"topics" must be an array'); + + const topics = obj.topics.map((t: unknown, i: number) => { + if (!t || typeof t !== 'object') throw new Error(`topics[${i}] must be an object`); + const topic = t as Record; + if (typeof topic.label !== 'string') throw new Error(`topics[${i}].label must be a string`); + if (typeof topic.kind !== 'string') throw new Error(`topics[${i}].kind must be a string`); + if (!Array.isArray(topic.messages)) throw new Error(`topics[${i}].messages must be an array`); + + const messages = topic.messages.map((m: unknown, j: number) => { + if (!m || typeof m !== 'object') throw new Error(`topics[${i}].messages[${j}] must be an object`); + const msg = m as Record; + if (typeof msg.messageId !== 'string') throw new Error(`topics[${i}].messages[${j}].messageId must be a string`); + const confidence = typeof msg.confidence === 'number' ? msg.confidence : 1.0; + return { messageId: msg.messageId, confidence: Math.max(0, Math.min(1, confidence)) }; + }); + + return { + id: typeof topic.id === 'string' ? topic.id : null, + label: topic.label, + kind: topic.kind, + messages, + }; + }); + + return { topics }; +} + +// ---------- Stage 2: Topic Summary ---------- + +export interface MessageSentiment { + messageId: string; + sentiment: number; +} + +export interface TopicSummaryOutput { + summary: string; + kind: string; + phase: string; + messageSentiments: MessageSentiment[]; +} + +export const TOPIC_SUMMARY_SCHEMA: Record = { + type: 'object', + required: ['summary', 'kind', 'phase', 'message_sentiments'], + properties: { + summary: { type: 'string' }, + kind: { type: 'string' }, + phase: { type: 'string', enum: ['active', 'stalled', 'closed'] }, + message_sentiments: { + type: 'array', + items: { + type: 'object', + required: ['messageId', 'sentiment'], + properties: { + messageId: { type: 'string' }, + sentiment: { type: 'number', minimum: -1, maximum: 1 }, + }, + }, + }, + }, + additionalProperties: false, +}; + +export function parseTopicSummary(payload: unknown): TopicSummaryOutput { + if (!payload || typeof payload !== 'object') throw new Error('expected object'); + const obj = payload as Record; + if (typeof obj.summary !== 'string') throw new Error('"summary" must be a string'); + if (typeof obj.kind !== 'string') throw new Error('"kind" must be a string'); + if (typeof obj.phase !== 'string') throw new Error('"phase" must be a string'); + const validPhases = ['active', 'stalled', 'closed']; + const phase = validPhases.includes(obj.phase) ? obj.phase : 'active'; + + const rawSentiments = Array.isArray(obj.message_sentiments) ? obj.message_sentiments : []; + const messageSentiments: MessageSentiment[] = rawSentiments + .filter((s): s is Record => s !== null && typeof s === 'object') + .flatMap((s) => { + if (typeof s.messageId !== 'string') return []; + const val = typeof s.sentiment === 'number' ? s.sentiment : parseFloat(String(s.sentiment)); + if (isNaN(val)) return []; + return [{ messageId: s.messageId, sentiment: Math.max(-1, Math.min(1, val)) }]; + }); + + return { summary: obj.summary, kind: obj.kind, phase, messageSentiments }; +} + +// ---------- Stage 0: Name Extraction ---------- + +export type NameDirection = 'contact_self' | 'quinn_self' | 'quinn_for_contact' | 'contact_for_quinn'; + +export interface NameEntry { + name: string; + direction: NameDirection; + firstMessageId: string; + lastMessageId: string; + context: string; +} + +export interface NameExtractionOutput { + names: NameEntry[]; +} + +export function parseNameExtraction(payload: unknown): NameExtractionOutput { + if (!payload || typeof payload !== 'object') throw new Error('expected object'); + const obj = payload as Record; + const rawNames = Array.isArray(obj.names) ? obj.names : []; + + const validDirections = new Set(['contact_self', 'quinn_self', 'quinn_for_contact', 'contact_for_quinn']); + + const names: NameEntry[] = rawNames + .filter((n): n is Record => n !== null && typeof n === 'object') + .flatMap((n) => { + if (typeof n.name !== 'string' || !n.name.trim()) return []; + if (typeof n.direction !== 'string' || !validDirections.has(n.direction)) return []; + if (typeof n.firstMessageId !== 'string') return []; + if (typeof n.lastMessageId !== 'string') return []; + const context = typeof n.context === 'string' ? n.context : ''; + return [{ + name: n.name.trim(), + direction: n.direction as NameDirection, + firstMessageId: n.firstMessageId, + lastMessageId: n.lastMessageId, + context, + }]; + }); + + return { names }; +} + +// ---------- Stage 3: Relationship Synthesis ---------- + +export interface RelationshipSynthesisOutput { + kind: string; + phase: string; + summary: string; + events: Array<{ + at: string; + kind: string; + description: string; + relatedContactName: string | null; + }>; + suggestedEmojis: string[]; +} + +export const RELATIONSHIP_SCHEMA: Record = { + type: 'object', + required: ['kind', 'phase', 'summary', 'events', 'suggested_emojis'], + properties: { + kind: { type: 'string' }, + phase: { type: 'string' }, + summary: { type: 'string' }, + events: { + type: 'array', + items: { + type: 'object', + required: ['at', 'kind', 'description'], + properties: { + at: { type: 'string' }, + kind: { type: 'string' }, + description: { type: 'string' }, + related_contact_name: { type: ['string', 'null'] }, + }, + }, + }, + suggested_emojis: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, +}; + +export function parseRelationshipSynthesis(payload: unknown): RelationshipSynthesisOutput { + if (!payload || typeof payload !== 'object') throw new Error('expected object'); + const obj = payload as Record; + const kind = Array.isArray(obj.kind) ? String(obj.kind[0] ?? 'ambiguous') : String(obj.kind ?? 'ambiguous'); + const phase = Array.isArray(obj.phase) ? String(obj.phase[0] ?? 'active') : String(obj.phase ?? 'active'); + const summary = typeof obj.summary === 'string' ? obj.summary : String(obj.summary ?? ''); + const rawEvents = Array.isArray(obj.events) ? obj.events : []; + const rawEmojis = Array.isArray(obj.suggested_emojis) ? obj.suggested_emojis : []; + + const events = rawEvents.map((e: unknown, i: number) => { + if (!e || typeof e !== 'object') throw new Error(`events[${i}] must be an object`); + const ev = e as Record; + if (typeof ev.at !== 'string') throw new Error(`events[${i}].at must be a string`); + if (typeof ev.kind !== 'string') throw new Error(`events[${i}].kind must be a string`); + if (typeof ev.description !== 'string') throw new Error(`events[${i}].description must be a string`); + return { + at: ev.at, + kind: ev.kind, + description: ev.description, + relatedContactName: typeof ev.related_contact_name === 'string' ? ev.related_contact_name : null, + }; + }); + + const suggestedEmojis = rawEmojis.filter( + (e): e is string => typeof e === 'string', + ); + + return { + kind, + phase, + summary, + events, + suggestedEmojis, + }; +}