commit for the purpose of working on another machine

This commit is contained in:
Will Freeman
2026-07-19 13:37:11 -06:00
parent 4f2676adc0
commit ad01b5a64d
29 changed files with 1905 additions and 10 deletions
+170
View File
@@ -0,0 +1,170 @@
import { describe, it, expect } from 'bun:test';
import { AiScreeningClient } from './AiScreeningClient';
function makeStubOpenAi(create: (...args: any[]) => Promise<any>) {
return { chat: { completions: { create } } } as any;
}
const validResult = {
ai_category: 'media_press',
media_tier: 'big_media',
confidence: 0.9,
kb_reference: 'none',
suggested_action: 'escalate_urgent',
suggested_reply: '',
internal_note: 'Reporter from a national outlet asking for comment, no stated deadline.',
risk_flags: [],
};
function completionWith(content: string) {
return { choices: [{ message: { content } }] };
}
describe('AiScreeningClient.screen', () => {
it('sends the system prompt, structured response_format, and a user message with only topic/subject/message/senderEmailDomain', async () => {
let capturedArgs: any;
let capturedOptions: any;
const create = async (args: any, options: any) => {
capturedArgs = args;
capturedOptions = options;
return completionWith(JSON.stringify(validResult));
};
const client = new AiScreeningClient(makeStubOpenAi(create));
await client.screen({
topic: 'media',
subject: 'Feature pitch',
message: 'We would like to interview your team.',
senderEmailDomain: 'nytimes.com',
});
expect(capturedArgs.messages[0].role).toBe('system');
expect(capturedArgs.messages[0].content.length).toBeGreaterThan(0);
expect(capturedArgs.response_format.type).toBe('json_schema');
expect(capturedArgs.response_format.json_schema.strict).toBe(true);
const userContent = JSON.parse(capturedArgs.messages[1].content);
expect(userContent).toEqual({
topic: 'media',
subject: 'Feature pitch',
message: 'We would like to interview your team.',
senderEmailDomain: 'nytimes.com',
});
expect(capturedOptions.timeout).toBe(20_000);
});
it('constrains kb_reference to "none" plus whatever .md filenames are actually loaded from kb/', async () => {
let capturedArgs: any;
const create = async (args: any) => {
capturedArgs = args;
return completionWith(JSON.stringify(validResult));
};
const client = new AiScreeningClient(makeStubOpenAi(create));
await client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' });
const enumValues: string[] = capturedArgs.response_format.json_schema.schema.properties.kb_reference.enum;
expect(enumValues).toContain('none');
// Every non-"none" value must correspond to a real, loaded .md file — never an invented name.
for (const value of enumValues) {
if (value !== 'none') expect(value.endsWith('.md')).toBe(true);
}
});
it('parses a valid structured response into a ScreeningResult', async () => {
const create = async () => completionWith(JSON.stringify(validResult));
const client = new AiScreeningClient(makeStubOpenAi(create));
const result = await client.screen({
topic: 'media',
subject: 'x',
message: 'y',
senderEmailDomain: 'nytimes.com',
});
expect(result).toEqual(validResult);
});
it('throws when the SDK call rejects', async () => {
const create = async () => { throw new Error('OpenAI request failed: 500 boom'); };
const client = new AiScreeningClient(makeStubOpenAi(create));
await expect(client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' }))
.rejects.toThrow('OpenAI request failed: 500 boom');
});
it('throws when the response has no content', async () => {
const create = async () => ({ choices: [{ message: {} }] });
const client = new AiScreeningClient(makeStubOpenAi(create));
await expect(client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' }))
.rejects.toThrow('OpenAI screening response missing content');
});
it('throws when the content is not valid JSON', async () => {
const create = async () => completionWith('not json');
const client = new AiScreeningClient(makeStubOpenAi(create));
await expect(client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' }))
.rejects.toThrow('OpenAI screening response was not valid JSON');
});
it('throws when the parsed JSON fails schema validation', async () => {
const { ai_category, ...incomplete } = validResult;
const create = async () => completionWith(JSON.stringify(incomplete));
const client = new AiScreeningClient(makeStubOpenAi(create));
await expect(client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' }))
.rejects.toThrow('OpenAI screening response failed schema validation');
});
it('throws when a value is outside the enum (e.g. an unrecognized ai_category)', async () => {
const create = async () => completionWith(JSON.stringify({ ...validResult, ai_category: 'made_up_category' }));
const client = new AiScreeningClient(makeStubOpenAi(create));
await expect(client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' }))
.rejects.toThrow('OpenAI screening response failed schema validation');
});
it('throws when internal_note is an empty string, rather than silently allowing a blank note', async () => {
const create = async () => completionWith(JSON.stringify({ ...validResult, internal_note: '' }));
const client = new AiScreeningClient(makeStubOpenAi(create));
await expect(client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' }))
.rejects.toThrow('OpenAI screening response failed schema validation');
});
it('throws when suggested_action is draft_response but suggested_reply is empty, rather than silently creating a blank shared draft', async () => {
const create = async () => completionWith(JSON.stringify({
...validResult,
ai_category: 'camera_report',
suggested_action: 'draft_response',
suggested_reply: '',
}));
const client = new AiScreeningClient(makeStubOpenAi(create));
await expect(client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' }))
.rejects.toThrow('OpenAI screening response has an empty suggested_reply for a draft_response category');
});
it('allows an empty suggested_reply for non-draft_response actions', async () => {
const create = async () => completionWith(JSON.stringify(validResult)); // escalate_urgent, empty reply
const client = new AiScreeningClient(makeStubOpenAi(create));
const result = await client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' });
expect(result.suggested_reply).toBe('');
});
it('passes when suggested_action is draft_response and suggested_reply is populated', async () => {
const create = async () => completionWith(JSON.stringify({
...validResult,
ai_category: 'camera_report',
suggested_action: 'draft_response',
suggested_reply: 'Here is how to report it yourself...',
}));
const client = new AiScreeningClient(makeStubOpenAi(create));
const result = await client.screen({ topic: 'media', subject: 'x', message: 'y', senderEmailDomain: '' });
expect(result.suggested_reply).toBe('Here is how to report it yourself...');
});
});
+163
View File
@@ -0,0 +1,163 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { Type, Static } from '@sinclair/typebox';
import { Value } from '@sinclair/typebox/value';
import OpenAI from 'openai';
import type { ContactTopic } from './ZammadClient';
import { loadKnowledgeBase, formatKnowledgeBaseForPrompt } from './KnowledgeBase';
const OPENAI_API_KEY = process.env.OPENAI_API_KEY || '';
const OPENAI_MODEL = process.env.OPENAI_MODEL || 'gpt-4o-mini';
const PROMPT_TEMPLATE = readFileSync(join(__dirname, '../prompts/contact-screening.md'), 'utf-8');
const KB_DOCS = loadKnowledgeBase();
const KB_FILENAMES = KB_DOCS.map(d => d.filename);
const KB_REFERENCE_VALUES = [...KB_FILENAMES, 'none'] as const;
const SYSTEM_PROMPT = `${PROMPT_TEMPLATE}\n\n${formatKnowledgeBaseForPrompt(KB_DOCS)}`;
export const AI_CATEGORIES = [
'local_group_request',
'camera_report',
'camera_correction',
'technical_bug',
'media_press',
'legal',
'donation',
'api_data',
'opinion_no_action',
'spam_bounce',
'other',
] as const;
// Only meaningful when ai_category is media_press; not_applicable otherwise.
export const MEDIA_TIER_VALUES = ['big_media', 'small_media', 'not_applicable'] as const;
export const RISK_FLAG_VALUES = [
'prompt_injection_suspected',
'abusive_or_threatening',
'spam_or_irrelevant',
] as const;
export const SUGGESTED_ACTION_VALUES = [
'draft_response',
'internal_note_only',
'escalate_urgent',
'auto_delete',
'scheduled_close',
] as const;
function literalUnion<T extends readonly string[]>(values: T) {
return Type.Union(values.map(v => Type.Literal(v)) as any);
}
export const ScreeningResultSchema = Type.Object({
ai_category: literalUnion(AI_CATEGORIES),
media_tier: literalUnion(MEDIA_TIER_VALUES),
confidence: Type.Number(),
kb_reference: literalUnion(KB_REFERENCE_VALUES),
suggested_action: literalUnion(SUGGESTED_ACTION_VALUES),
suggested_reply: Type.String(),
// Required and non-empty: the schema only guarantees the key is present, so without
// minLength the model can (and did) satisfy "required" with an empty string. This is
// enforced locally by Value.Check below regardless of whether OpenAI's strict mode honors
// minLength on its end — an empty note now fails loudly instead of silently writing nothing.
internal_note: Type.String({ minLength: 1 }),
risk_flags: Type.Array(literalUnion(RISK_FLAG_VALUES)),
});
export type ScreeningResult = Static<typeof ScreeningResultSchema>;
export interface ScreeningInput {
topic: ContactTopic;
subject: string;
message: string;
senderEmailDomain: string;
}
// Mirrors ScreeningResultSchema. OpenAI Structured Outputs strict mode requires every
// property to be listed in "required" and additionalProperties: false throughout.
const OPENAI_JSON_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
ai_category: { type: 'string', enum: AI_CATEGORIES },
media_tier: { type: 'string', enum: MEDIA_TIER_VALUES },
confidence: { type: 'number', minimum: 0, maximum: 1 },
kb_reference: { type: 'string', enum: KB_REFERENCE_VALUES },
suggested_action: { type: 'string', enum: SUGGESTED_ACTION_VALUES },
suggested_reply: { type: 'string' },
internal_note: { type: 'string', minLength: 1 },
risk_flags: { type: 'array', items: { type: 'string', enum: RISK_FLAG_VALUES } },
},
required: [
'ai_category',
'media_tier',
'confidence',
'kb_reference',
'suggested_action',
'suggested_reply',
'internal_note',
'risk_flags',
],
};
export class AiScreeningClient {
private readonly openai: OpenAI;
constructor(openai: OpenAI = new OpenAI({ apiKey: OPENAI_API_KEY })) {
this.openai = openai;
}
async screen(input: ScreeningInput): Promise<ScreeningResult> {
const response = await this.openai.chat.completions.create(
{
model: OPENAI_MODEL,
temperature: 0.2,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{
role: 'user',
content: JSON.stringify({
topic: input.topic,
subject: input.subject,
message: input.message,
senderEmailDomain: input.senderEmailDomain,
}),
},
],
response_format: {
type: 'json_schema',
json_schema: { name: 'contact_screening_result', strict: true, schema: OPENAI_JSON_SCHEMA },
},
},
{ timeout: 20_000 },
);
const content = response.choices?.[0]?.message?.content;
if (!content) {
throw new Error('OpenAI screening response missing content');
}
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
throw new Error('OpenAI screening response was not valid JSON');
}
if (!Value.Check(ScreeningResultSchema, parsed)) {
throw new Error('OpenAI screening response failed schema validation');
}
// suggested_reply is legitimately empty for non-draft categories (media_press, legal,
// opinion_no_action, spam_bounce), so it can't just be minLength'd in the schema like
// internal_note was. But when suggested_action is draft_response, an empty reply means an
// empty Zammad shared draft gets silently created with no error — enforce it here instead.
if (parsed.suggested_action === 'draft_response' && !parsed.suggested_reply.trim()) {
throw new Error('OpenAI screening response has an empty suggested_reply for a draft_response category');
}
return parsed;
}
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'bun:test';
import { endOfWorkdayEasternIso } from './BusinessHours';
describe('endOfWorkdayEasternIso', () => {
it('resolves to 21:00 UTC (5pm EDT) during daylight saving time', () => {
const now = new Date('2026-07-16T12:00:00Z'); // July -> EDT, UTC-4
expect(endOfWorkdayEasternIso(now)).toBe('2026-07-16T21:00:00.000Z');
});
it('resolves to 22:00 UTC (5pm EST) outside daylight saving time', () => {
const now = new Date('2026-01-16T12:00:00Z'); // January -> EST, UTC-5
expect(endOfWorkdayEasternIso(now)).toBe('2026-01-16T22:00:00.000Z');
});
it('uses the New York calendar date, not the UTC calendar date', () => {
// 1am UTC on the 17th is still 9pm on the 16th in New York (EDT, UTC-4)
const now = new Date('2026-07-17T01:00:00Z');
expect(endOfWorkdayEasternIso(now)).toBe('2026-07-16T21:00:00.000Z');
});
});
+42
View File
@@ -0,0 +1,42 @@
const WORKDAY_TIMEZONE = 'America/New_York';
const WORKDAY_END_HOUR = 17;
function getTzOffsetMinutes(timeZone: string, utcMs: number): number {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const parts = dtf.formatToParts(new Date(utcMs));
const map: Record<string, string> = {};
for (const p of parts) {
if (p.type !== 'literal') map[p.type] = p.value;
}
const asIfUtc = Date.UTC(+map.year, +map.month - 1, +map.day, +map.hour, +map.minute, +map.second);
return (asIfUtc - utcMs) / 60_000;
}
/**
* Returns an ISO 8601 UTC timestamp for 5:00 PM America/New_York on the
* calendar date `now` falls on in that timezone. Correctly accounts for
* EST/EDT by computing the actual UTC offset for that specific date.
*/
export function endOfWorkdayEasternIso(now: Date = new Date()): string {
const dateParts = new Intl.DateTimeFormat('en-CA', {
timeZone: WORKDAY_TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
const [year, month, day] = dateParts.split('-').map(Number);
const naiveUtcMs = Date.UTC(year, month - 1, day, WORKDAY_END_HOUR, 0, 0);
const offsetMinutes = getTzOffsetMinutes(WORKDAY_TIMEZONE, naiveUtcMs);
const targetUtcMs = naiveUtcMs - offsetMinutes * 60_000;
return new Date(targetUtcMs).toISOString();
}
@@ -0,0 +1,227 @@
import { describe, it, expect, mock } from 'bun:test';
import { planZammadActions, screenContactSubmission } from './ContactScreeningService';
import type { ScreeningResult } from './AiScreeningClient';
const base: ScreeningResult = {
ai_category: 'other',
media_tier: 'not_applicable',
confidence: 0.5,
kb_reference: 'none',
suggested_action: 'internal_note_only',
suggested_reply: '',
internal_note: 'Unclear request.',
risk_flags: [],
};
function makeDeps(screenResult: ScreeningResult) {
const addTag = mock(async () => {});
const addInternalNote = mock(async () => {});
const setTicketFields = mock(async () => {});
const upsertSharedDraft = mock(async () => {});
const aiClient = { screen: mock(async () => screenResult) } as any;
const zammadClient = { addTag, addInternalNote, setTicketFields, upsertSharedDraft } as any;
return { aiClient, zammadClient, addTag, addInternalNote, setTicketFields, upsertSharedDraft };
}
describe('planZammadActions', () => {
it('media_press (big_media): escalates, tags media + media-big, bumps priority, routes to Media group, no shared draft, writes a note', () => {
const plan = planZammadActions({
...base,
ai_category: 'media_press',
media_tier: 'big_media',
suggested_action: 'escalate_urgent',
internal_note: 'AP reporter, deadline Friday.',
});
expect(plan.tags).toEqual(expect.arrayContaining(['ai-screened', 'media', 'media-big']));
expect(plan.tags).not.toContain('media-small');
expect(plan.priorityUpdate).toBe('3 high');
expect(plan.groupOverride).toBe('Media');
expect(plan.sharedDraft).toBeNull();
expect(plan.noteBody).toBe('AP reporter, deadline Friday.');
expect(plan.state).toBeNull();
});
it('media_press (small_media): tags media + media-small, still escalates and routes to Media group', () => {
const plan = planZammadActions({ ...base, ai_category: 'media_press', media_tier: 'small_media', suggested_action: 'escalate_urgent' });
expect(plan.tags).toContain('media-small');
expect(plan.tags).not.toContain('media-big');
expect(plan.groupOverride).toBe('Media');
expect(plan.priorityUpdate).toBe('3 high');
});
it('legal: escalates, tags legal, bumps priority, does not route to Media group', () => {
const plan = planZammadActions({ ...base, ai_category: 'legal', suggested_action: 'escalate_urgent' });
expect(plan.tags).toContain('legal');
expect(plan.priorityUpdate).toBe('3 high');
expect(plan.groupOverride).toBeNull();
});
it('camera_correction: tags camera-correction and is KB-grounded (kb-gap when uncited)', () => {
const plan = planZammadActions({ ...base, ai_category: 'camera_correction', suggested_action: 'draft_response', kb_reference: 'none' });
expect(plan.tags).toContain('camera-correction');
expect(plan.tags).toContain('kb-gap');
});
it('technical_bug: writes both a shared draft and an internal note', () => {
const plan = planZammadActions({ ...base, ai_category: 'technical_bug', suggested_action: 'draft_response', internal_note: 'Map fails to load on Safari.' });
expect(plan.sharedDraft).not.toBeNull();
expect(plan.noteBody).toBe('Map fails to load on Safari.');
expect(plan.tags).toContain('technical-bug');
});
it('local_group_request: writes both a shared draft and an internal note summary', () => {
const plan = planZammadActions({ ...base, ai_category: 'local_group_request', suggested_action: 'draft_response', internal_note: 'Wants to start a group in Ohio.' });
expect(plan.sharedDraft).not.toBeNull();
expect(plan.noteBody).toBe('Wants to start a group in Ohio.');
});
it('opinion_no_action: schedules a pending close, no draft, priority set to low', () => {
const plan = planZammadActions({ ...base, ai_category: 'opinion_no_action', suggested_action: 'scheduled_close', internal_note: 'Unsolicited opinion.' });
expect(plan.sharedDraft).toBeNull();
expect(plan.state).toBe('pending close');
expect(plan.pendingTime).not.toBeNull();
expect(plan.priorityUpdate).toBe('1 low');
expect(plan.noteBody).toBe('Unsolicited opinion.');
});
it('spam_bounce: schedules a pending close via auto_delete, priority set to low', () => {
const plan = planZammadActions({ ...base, ai_category: 'spam_bounce', suggested_action: 'auto_delete', internal_note: 'Spam.' });
expect(plan.state).toBe('pending close');
expect(plan.pendingTime).not.toBeNull();
expect(plan.sharedDraft).toBeNull();
expect(plan.priorityUpdate).toBe('1 low');
});
it('donation/api_data/other/camera_correction: tag kb-gap when kb_reference is none', () => {
const plan = planZammadActions({ ...base, ai_category: 'donation', suggested_action: 'internal_note_only', kb_reference: 'none' });
expect(plan.tags).toContain('kb-gap');
});
it('camera_report and local_group_request are also KB-grounded and tag kb-gap when kb_reference is none', () => {
const cameraReport = planZammadActions({ ...base, ai_category: 'camera_report', suggested_action: 'draft_response', kb_reference: 'none' });
expect(cameraReport.tags).toContain('kb-gap');
const localGroup = planZammadActions({ ...base, ai_category: 'local_group_request', suggested_action: 'draft_response', kb_reference: 'none' });
expect(localGroup.tags).toContain('kb-gap');
});
it('does not tag kb-gap when a kb_reference was cited', () => {
const plan = planZammadActions({ ...base, ai_category: 'donation', suggested_action: 'draft_response', kb_reference: 'donations.md' });
expect(plan.tags).not.toContain('kb-gap');
});
it('does not tag kb-gap for categories that are not KB-grounded', () => {
const plan = planZammadActions({ ...base, ai_category: 'technical_bug', suggested_action: 'draft_response', kb_reference: 'none' });
expect(plan.tags).not.toContain('kb-gap');
});
it('prefixes risk_flags as stackable tags', () => {
const plan = planZammadActions({ ...base, risk_flags: ['abusive_or_threatening'] });
expect(plan.tags).toContain('risk:abusive_or_threatening');
});
it('always includes the ai_category ticket attribute', () => {
const plan = planZammadActions({ ...base, ai_category: 'donation' });
expect(plan.ticketAttribute).toEqual({ ai_category: 'donation' });
});
it('does not route non-media categories to the Media group', () => {
const plan = planZammadActions({ ...base, ai_category: 'donation' });
expect(plan.groupOverride).toBeNull();
});
});
describe('screenContactSubmission', () => {
it('sets ticket fields, applies tags, writes the shared draft, and still leaves an internal note summary', async () => {
const deps = makeDeps({ ...base, ai_category: 'local_group_request', suggested_action: 'draft_response', suggested_reply: 'Here is how to start a group...', internal_note: 'Wants to start a group in Ohio.' });
await screenContactSubmission(
{ ticketId: 1, topic: 'local-groups', subject: 's', message: 'm', senderEmailDomain: '', replyTo: 'jane@example.com' },
deps,
);
expect(deps.setTicketFields).toHaveBeenCalledWith(1, { ai_category: 'local_group_request' });
expect(deps.upsertSharedDraft).toHaveBeenCalledWith(1, 'Here is how to start a group...', { to: 'jane@example.com', cc: undefined });
expect(deps.addInternalNote).toHaveBeenCalledTimes(1);
expect(deps.addInternalNote.mock.calls[0][1]).toContain('Wants to start a group in Ohio.');
const appliedTags = deps.addTag.mock.calls.map(c => c[1]);
expect(appliedTags).toContain('ai-screened');
expect(appliedTags).not.toContain('draft-fallback');
});
it('returns the classification result and plan for the caller to log/inspect', async () => {
const deps = makeDeps({ ...base, ai_category: 'local_group_request', suggested_action: 'draft_response', suggested_reply: 'Draft text' });
const outcome = await screenContactSubmission(
{ ticketId: 1, topic: 'local-groups', subject: 's', message: 'm', senderEmailDomain: '', replyTo: 'jane@example.com' },
deps,
);
expect(outcome.result.ai_category).toBe('local_group_request');
expect(outcome.plan.sharedDraft).not.toBeNull();
expect(outcome.appliedTags).toContain('ai-screened');
expect(outcome.noteWritten).toBe(true);
});
it('falls back to an internal note with a draft-fallback tag when the shared draft write fails', async () => {
const deps = makeDeps({ ...base, ai_category: 'local_group_request', suggested_action: 'draft_response', suggested_reply: 'Draft text' });
deps.upsertSharedDraft.mockImplementation(async () => { throw new Error('shared_drafts not enabled on this group'); });
await screenContactSubmission(
{ ticketId: 2, topic: 'local-groups', subject: 's', message: 'm', senderEmailDomain: '', replyTo: 'jane@example.com' },
deps,
);
const appliedTags = deps.addTag.mock.calls.map(c => c[1]);
expect(appliedTags).toContain('draft-fallback');
expect(deps.addInternalNote).toHaveBeenCalledTimes(1);
const noteBody = deps.addInternalNote.mock.calls[0][1];
expect(noteBody).toContain('Draft text');
});
it('includes priority/group in the single ticket-fields update for a big media inquiry', async () => {
const deps = makeDeps({ ...base, ai_category: 'media_press', media_tier: 'big_media', suggested_action: 'escalate_urgent' });
await screenContactSubmission(
{ ticketId: 3, topic: 'questions-comments', subject: 's', message: 'm', senderEmailDomain: '', replyTo: 'jane@example.com' },
deps,
);
const fields = deps.setTicketFields.mock.calls[0][1];
expect(fields.ai_category).toBe('media_press');
expect(fields.priority).toBe('3 high');
expect(fields.group).toBe('Media');
});
it('sets low priority and pending close for spam_bounce', async () => {
const deps = makeDeps({ ...base, ai_category: 'spam_bounce', suggested_action: 'auto_delete' });
await screenContactSubmission(
{ ticketId: 4, topic: 'questions-comments', subject: 's', message: 'm', senderEmailDomain: '', replyTo: 'jane@example.com' },
deps,
);
const fields = deps.setTicketFields.mock.calls[0][1];
expect(fields.priority).toBe('1 low');
expect(fields.state).toBe('pending close');
});
it('propagates a rejection from the AI client without swallowing it', async () => {
const zammadClient = {
addTag: mock(async () => {}),
addInternalNote: mock(async () => {}),
setTicketFields: mock(async () => {}),
upsertSharedDraft: mock(async () => {}),
} as any;
const aiClient = { screen: mock(async () => { throw new Error('OpenAI down'); }) } as any;
await expect(screenContactSubmission(
{ ticketId: 5, topic: 'media', subject: 's', message: 'm', senderEmailDomain: '', replyTo: 'jane@example.com' },
{ aiClient, zammadClient },
)).rejects.toThrow('OpenAI down');
expect(zammadClient.setTicketFields).not.toHaveBeenCalled();
});
});
+137
View File
@@ -0,0 +1,137 @@
import { TOPIC_GROUP_MAP, type ContactTopic, type ZammadClient } from './ZammadClient';
import type { AiScreeningClient, ScreeningResult } from './AiScreeningClient';
import { endOfWorkdayEasternIso } from './BusinessHours';
const KB_GROUNDED_CATEGORIES = new Set(['donation', 'api_data', 'other', 'camera_correction', 'camera_report', 'local_group_request']);
export interface ZammadActionPlan {
ticketAttribute: { ai_category: string };
tags: string[];
sharedDraft: { body: string; type: 'email'; internal: false } | null;
state: 'pending close' | null;
pendingTime: string | null;
noteBody: string;
priorityUpdate: '1 low' | '3 high' | null;
groupOverride: string | null;
}
export function planZammadActions(result: ScreeningResult): ZammadActionPlan {
const tags = ['ai-screened'];
for (const flag of result.risk_flags) tags.push(`risk:${flag}`);
if (result.ai_category === 'technical_bug') tags.push('technical-bug');
if (result.ai_category === 'camera_correction') tags.push('camera-correction');
if (result.ai_category === 'media_press') {
tags.push('media');
if (result.media_tier === 'big_media') tags.push('media-big');
if (result.media_tier === 'small_media') tags.push('media-small');
}
if (result.ai_category === 'legal') tags.push('legal');
if (KB_GROUNDED_CATEGORIES.has(result.ai_category) && result.kb_reference === 'none') {
tags.push('kb-gap');
}
const sharedDraft = result.suggested_action === 'draft_response'
? { body: result.suggested_reply, type: 'email' as const, internal: false as const }
: null;
const priorityUpdate = result.suggested_action === 'escalate_urgent'
? '3 high' as const
: (result.suggested_action === 'auto_delete' || result.suggested_action === 'scheduled_close')
? '1 low' as const
: null;
// Sender may have picked the wrong topic on the form (e.g. contacted General Support by
// mistake) — route media inquiries to the Media group regardless of what they chose.
const groupOverride = result.ai_category === 'media_press' ? TOPIC_GROUP_MAP.media : null;
const isPendingClose = result.suggested_action === 'scheduled_close' || result.suggested_action === 'auto_delete';
const state = isPendingClose ? 'pending close' as const : null;
const pendingTime = isPendingClose ? endOfWorkdayEasternIso() : null;
return {
ticketAttribute: { ai_category: result.ai_category },
tags,
sharedDraft,
state,
pendingTime,
// Always populated (internal_note is required and non-empty per the schema) — even
// draft_response categories get a note now, so an agent reviewing a shared draft still
// gets a quick summary of the AI's reasoning without opening the compose box.
noteBody: result.internal_note,
priorityUpdate,
groupOverride,
};
}
export interface ScreenContactSubmissionInput {
ticketId: number;
topic: ContactTopic;
subject: string;
message: string;
senderEmailDomain: string;
// Recipient(s) for the shared draft reply. replyTo is the customer's address (used whenever
// there's nothing richer to reply-all to); replyCc carries any other correspondents on the
// ticket so the draft doesn't drop people who were already on the thread.
replyTo: string;
replyCc?: string;
}
export interface ScreenContactSubmissionDeps {
aiClient: AiScreeningClient;
zammadClient: ZammadClient;
}
export interface ScreenContactSubmissionResult {
result: ScreeningResult;
plan: ZammadActionPlan;
appliedTags: string[];
noteWritten: boolean;
}
export async function screenContactSubmission(
input: ScreenContactSubmissionInput,
deps: ScreenContactSubmissionDeps,
): Promise<ScreenContactSubmissionResult> {
const result = await deps.aiClient.screen({
topic: input.topic,
subject: input.subject,
message: input.message,
senderEmailDomain: input.senderEmailDomain,
});
const plan = planZammadActions(result);
const ticketFields: Record<string, unknown> = { ai_category: plan.ticketAttribute.ai_category };
if (plan.priorityUpdate) ticketFields.priority = plan.priorityUpdate;
if (plan.state) ticketFields.state = plan.state;
if (plan.pendingTime) ticketFields.pending_time = plan.pendingTime;
if (plan.groupOverride) ticketFields.group = plan.groupOverride;
await deps.zammadClient.setTicketFields(input.ticketId, ticketFields);
const tags = [...plan.tags];
let noteBody = plan.noteBody
? `🤖 AI Triage:\n\n${plan.noteBody}`
: null;
if (plan.sharedDraft) {
try {
await deps.zammadClient.upsertSharedDraft(input.ticketId, plan.sharedDraft.body, {
to: input.replyTo,
cc: input.replyCc,
});
} catch {
tags.push('draft-fallback');
noteBody = `🤖 AI-drafted reply (shared draft failed — unreviewed, edit/approve before sending):\n\n${plan.sharedDraft.body}` +
(plan.noteBody ? `\n\n---\n${plan.noteBody}` : '');
}
}
await Promise.all(tags.map(tag => deps.zammadClient.addTag(input.ticketId, tag)));
if (noteBody) {
await deps.zammadClient.addInternalNote(input.ticketId, noteBody);
}
return { result, plan, appliedTags: tags, noteWritten: Boolean(noteBody) };
}
+33
View File
@@ -0,0 +1,33 @@
import { existsSync, readdirSync, readFileSync } from 'fs';
import { join } from 'path';
const KB_DIR = join(__dirname, '../../kb');
export interface KbDocument {
filename: string;
content: string;
}
export function loadKnowledgeBase(dir: string = KB_DIR): KbDocument[] {
if (!existsSync(dir)) return [];
return readdirSync(dir)
// CLAUDE.md documents this directory's conventions for engineers — it's not grounding
// content and must never be citable as a kb_reference.
.filter(f => f.endsWith('.md') && f.toUpperCase() !== 'CLAUDE.MD')
.sort()
.map(filename => ({ filename, content: readFileSync(join(dir, filename), 'utf-8') }));
}
export function formatKnowledgeBaseForPrompt(docs: KbDocument[]): string {
if (docs.length === 0) {
return [
'## Knowledge base',
'',
'No knowledge base documents are currently loaded. Always set kb_reference to "none". For',
'donation, api_data, camera_correction, and other, this means you cannot ground a reply',
'in policy yet — set suggested_action to internal_note_only rather than inventing an answer.',
].join('\n');
}
const sections = docs.map(d => `### ${d.filename}\n\n${d.content}`).join('\n\n---\n\n');
return `## Knowledge base\n\nCite the filename of the document you draw from as kb_reference. If none of these\ndocuments cover the question, set kb_reference to "none".\n\n${sections}`;
}
+257
View File
@@ -0,0 +1,257 @@
import { describe, it, expect, afterEach, mock } from 'bun:test';
import { ZammadClient } from './ZammadClient';
describe('ZammadClient.createTicket', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});
it('creates a customer (when none exists) then the ticket, and returns its id', async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
global.fetch = mock(async (url: string, init?: RequestInit) => {
calls.push({ url, init });
if (url.includes('/users/search')) {
return new Response(JSON.stringify([]));
}
if (url.includes('/users') && init?.method === 'POST') {
return new Response(JSON.stringify({ id: 42 }));
}
if (url.includes('/tickets') && init?.method === 'POST') {
return new Response(JSON.stringify({ id: 99 }));
}
throw new Error(`Unexpected fetch: ${url}`);
}) as unknown as typeof fetch;
const client = new ZammadClient();
const result = await client.createTicket({
name: 'Jane Doe',
email: 'jane@example.com',
topic: 'media',
subject: 'Story inquiry',
message: 'Hello',
});
expect(result).toEqual({ id: 99 });
const ticketCall = calls.find(c => c.url.includes('/tickets') && c.init?.method === 'POST');
const body = JSON.parse(ticketCall!.init!.body as string);
expect(body.priority).toBe('2 normal');
expect(body.customer_id).toBe(42);
});
it('throws a descriptive error when ticket creation fails', async () => {
global.fetch = mock(async (url: string) => {
if (url.includes('/users/search')) return new Response(JSON.stringify([{ id: 1, email: 'jane@example.com' }]));
if (url.includes('/tickets')) return new Response('boom', { status: 500 });
throw new Error(`Unexpected fetch: ${url}`);
}) as unknown as typeof fetch;
const client = new ZammadClient();
await expect(client.createTicket({
name: 'Jane Doe',
email: 'jane@example.com',
topic: 'app-support',
subject: 'Bug',
message: 'It broke',
})).rejects.toThrow('Zammad ticket creation failed: 500 boom');
});
});
describe('ZammadClient.addTag', () => {
const originalFetch = global.fetch;
afterEach(() => { global.fetch = originalFetch; });
it('POSTs to /api/v1/tags/add with the tag under "item" (Zammad ignores "tag" and leaves it nil)', async () => {
let captured: { url: string; body: any } | undefined;
global.fetch = mock(async (url: string, init?: RequestInit) => {
captured = { url, body: JSON.parse(init!.body as string) };
return new Response('{}');
}) as unknown as typeof fetch;
const client = new ZammadClient();
await client.addTag(99, 'ai-screened');
expect(captured!.url).toContain('/api/v1/tags/add');
expect(captured!.body).toEqual({ item: 'ai-screened', object: 'Ticket', o_id: 99 });
});
it('throws a descriptive error on failure', async () => {
global.fetch = mock(async () => new Response('nope', { status: 422 })) as unknown as typeof fetch;
const client = new ZammadClient();
await expect(client.addTag(99, 'ai-screened')).rejects.toThrow('Zammad tag creation failed for tag "ai-screened" on ticket 99: 422 nope');
});
});
describe('ZammadClient.addInternalNote', () => {
const originalFetch = global.fetch;
afterEach(() => { global.fetch = originalFetch; });
it('POSTs to /api/v1/ticket_articles as an internal note', async () => {
let captured: { url: string; body: any } | undefined;
global.fetch = mock(async (url: string, init?: RequestInit) => {
captured = { url, body: JSON.parse(init!.body as string) };
return new Response('{}');
}) as unknown as typeof fetch;
const client = new ZammadClient();
await client.addInternalNote(99, 'note body');
expect(captured!.url).toContain('/api/v1/ticket_articles');
expect(captured!.body).toEqual({ ticket_id: 99, body: 'note body', type: 'note', internal: true });
});
it('throws a descriptive error on failure', async () => {
global.fetch = mock(async () => new Response('nope', { status: 500 })) as unknown as typeof fetch;
const client = new ZammadClient();
await expect(client.addInternalNote(99, 'note body')).rejects.toThrow('Zammad internal note creation failed: 500 nope');
});
});
describe('ZammadClient.setTicketFields', () => {
const originalFetch = global.fetch;
afterEach(() => { global.fetch = originalFetch; });
it('PUTs to /api/v1/tickets/{id} with the given fields, whatever they are', async () => {
let captured: { url: string; method?: string; body: any } | undefined;
global.fetch = mock(async (url: string, init?: RequestInit) => {
captured = { url, method: init?.method, body: JSON.parse(init!.body as string) };
return new Response('{}');
}) as unknown as typeof fetch;
const client = new ZammadClient();
await client.setTicketFields(99, {
ai_category: 'media_press',
priority: '3 high',
state: 'pending close',
pending_time: '2026-07-16T21:00:00.000Z',
});
expect(captured!.url).toContain('/api/v1/tickets/99');
expect(captured!.method).toBe('PUT');
expect(captured!.body).toEqual({
ai_category: 'media_press',
priority: '3 high',
state: 'pending close',
pending_time: '2026-07-16T21:00:00.000Z',
});
});
it('throws a descriptive error on failure', async () => {
global.fetch = mock(async () => new Response('nope', { status: 500 })) as unknown as typeof fetch;
const client = new ZammadClient();
await expect(client.setTicketFields(99, { priority: '3 high' })).rejects.toThrow('Zammad ticket fields update failed: 500 nope');
});
});
describe('ZammadClient.upsertSharedDraft', () => {
const originalFetch = global.fetch;
afterEach(() => { global.fetch = originalFetch; });
it('PUTs to /api/v1/tickets/{id}/shared_draft with the body nested under new_article (Zammad strips top-level keys)', async () => {
let captured: { url: string; method?: string; body: any } | undefined;
global.fetch = mock(async (url: string, init?: RequestInit) => {
captured = { url, method: init?.method, body: JSON.parse(init!.body as string) };
return new Response('{}');
}) as unknown as typeof fetch;
const client = new ZammadClient();
await client.upsertSharedDraft(99, 'Draft reply text', { to: 'jane@example.com', cc: 'other@example.com' });
expect(captured!.url).toContain('/api/v1/tickets/99/shared_draft');
expect(captured!.method).toBe('PUT');
expect(captured!.body).toEqual({
new_article: { body: 'Draft reply text', type: 'email', internal: false, to: 'jane@example.com', cc: 'other@example.com' },
ticket_attributes: {},
});
});
it('defaults cc to an empty string when not provided', async () => {
let captured: { body: any } | undefined;
global.fetch = mock(async (_url: string, init?: RequestInit) => {
captured = { body: JSON.parse(init!.body as string) };
return new Response('{}');
}) as unknown as typeof fetch;
const client = new ZammadClient();
await client.upsertSharedDraft(99, 'Draft reply text', { to: 'jane@example.com' });
expect(captured!.body.new_article.cc).toBe('');
});
it('throws a descriptive error on failure', async () => {
global.fetch = mock(async () => new Response('nope', { status: 422 })) as unknown as typeof fetch;
const client = new ZammadClient();
await expect(client.upsertSharedDraft(99, 'text', { to: 'jane@example.com' })).rejects.toThrow('Zammad shared draft update failed: 422 nope');
});
});
describe('ZammadClient.getTicket', () => {
const originalFetch = global.fetch;
afterEach(() => { global.fetch = originalFetch; });
it('GETs /api/v1/tickets/{id} and returns the parsed ticket', async () => {
global.fetch = mock(async () =>
new Response(JSON.stringify({ id: 99, title: 'Story inquiry', group: 'Media', customer_id: 42 }))
) as unknown as typeof fetch;
const client = new ZammadClient();
const ticket = await client.getTicket(99);
expect(ticket).toEqual({ id: 99, title: 'Story inquiry', group: 'Media', customer_id: 42 });
});
it('throws a descriptive error on failure', async () => {
global.fetch = mock(async () => new Response('nope', { status: 404 })) as unknown as typeof fetch;
const client = new ZammadClient();
await expect(client.getTicket(99)).rejects.toThrow('Zammad ticket fetch failed: 404 nope');
});
});
describe('ZammadClient.getTicketArticles', () => {
const originalFetch = global.fetch;
afterEach(() => { global.fetch = originalFetch; });
it('GETs /api/v1/ticket_articles/by_ticket/{id} and returns the parsed articles', async () => {
const articles = [{ body: '<p>Hello</p>', content_type: 'text/html' }];
global.fetch = mock(async (url: string) => {
expect(url).toContain('/api/v1/ticket_articles/by_ticket/99');
return new Response(JSON.stringify(articles));
}) as unknown as typeof fetch;
const client = new ZammadClient();
const result = await client.getTicketArticles(99);
expect(result).toEqual(articles);
});
it('throws a descriptive error on failure', async () => {
global.fetch = mock(async () => new Response('nope', { status: 500 })) as unknown as typeof fetch;
const client = new ZammadClient();
await expect(client.getTicketArticles(99)).rejects.toThrow('Zammad ticket articles fetch failed: 500 nope');
});
});
describe('ZammadClient.getUser', () => {
const originalFetch = global.fetch;
afterEach(() => { global.fetch = originalFetch; });
it('GETs /api/v1/users/{id} and returns the parsed user', async () => {
global.fetch = mock(async (url: string) => {
expect(url).toContain('/api/v1/users/42');
return new Response(JSON.stringify({ email: 'jane@nytimes.com' }));
}) as unknown as typeof fetch;
const client = new ZammadClient();
const user = await client.getUser(42);
expect(user).toEqual({ email: 'jane@nytimes.com' });
});
it('throws a descriptive error on failure', async () => {
global.fetch = mock(async () => new Response('nope', { status: 404 })) as unknown as typeof fetch;
const client = new ZammadClient();
await expect(client.getUser(42)).rejects.toThrow('Zammad user fetch failed: 404 nope');
});
});
+109 -3
View File
@@ -23,11 +23,12 @@ export const ContactMessageBodySchema = Type.Object({
subject: Type.String({ minLength: 1 }),
message: Type.String({ minLength: 1 }),
turnstileToken: Type.String({ minLength: 1 }),
aiScreeningOptOut: Type.Optional(Type.Boolean({ default: false })),
});
export type ContactMessageBody = Static<typeof ContactMessageBodySchema>;
const TOPIC_GROUP_MAP: Record<ContactTopic, string> = {
export const TOPIC_GROUP_MAP: Record<ContactTopic, string> = {
'website-support': 'Website Support',
'app-support': 'App Support',
'local-groups': 'Local Groups',
@@ -77,7 +78,7 @@ export class ZammadClient {
return user.id;
}
async createTicket(payload: CreateTicketPayload): Promise<void> {
async createTicket(payload: CreateTicketPayload): Promise<{ id: number }> {
const { name, email, topic, subject, message } = payload;
const group = TOPIC_GROUP_MAP[topic];
@@ -86,7 +87,7 @@ export class ZammadClient {
const body = JSON.stringify({
title: subject,
group,
priority: topic === 'media' ? '3 high' : '2 normal',
priority: '2 normal',
customer_id: customerId,
article: {
subject,
@@ -111,5 +112,110 @@ export class ZammadClient {
const text = await response.text();
throw new Error(`Zammad ticket creation failed: ${response.status} ${text}`);
}
const ticket = await response.json() as { id: number };
return { id: ticket.id };
}
async getTicket(ticketId: number): Promise<{ id: number; title: string; group: string; customer_id: number }> {
const response = await fetch(`${ZAMMAD_URL}/api/v1/tickets/${ticketId}`, {
headers: { 'Authorization': `Token token=${ZAMMAD_TOKEN}` },
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Zammad ticket fetch failed: ${response.status} ${text}`);
}
return response.json();
}
async getTicketArticles(ticketId: number): Promise<Array<{ body: string; content_type: string; from: string; to: string; cc: string; sender: string }>> {
const response = await fetch(`${ZAMMAD_URL}/api/v1/ticket_articles/by_ticket/${ticketId}`, {
headers: { 'Authorization': `Token token=${ZAMMAD_TOKEN}` },
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Zammad ticket articles fetch failed: ${response.status} ${text}`);
}
return response.json();
}
async getUser(userId: number): Promise<{ email: string }> {
const response = await fetch(`${ZAMMAD_URL}/api/v1/users/${userId}`, {
headers: { 'Authorization': `Token token=${ZAMMAD_TOKEN}` },
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Zammad user fetch failed: ${response.status} ${text}`);
}
return response.json();
}
async addTag(ticketId: number, tag: string): Promise<void> {
const response = await fetch(`${ZAMMAD_URL}/api/v1/tags/add`, {
method: 'POST',
headers: {
'Authorization': `Token token=${ZAMMAD_TOKEN}`,
'Content-Type': 'application/json',
},
// Zammad's tag-add endpoint reads the tag name from "item", not "tag" — sending the
// wrong key leaves it nil server-side and crashes Zammad's own tag_add with an
// unhandled NoMethodError (500) rather than a clean validation error.
body: JSON.stringify({ item: tag, object: 'Ticket', o_id: ticketId }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Zammad tag creation failed for tag "${tag}" on ticket ${ticketId}: ${response.status} ${text}`);
}
}
async addInternalNote(ticketId: number, body: string): Promise<void> {
const response = await fetch(`${ZAMMAD_URL}/api/v1/ticket_articles`, {
method: 'POST',
headers: {
'Authorization': `Token token=${ZAMMAD_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ ticket_id: ticketId, body, type: 'note', internal: true }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Zammad internal note creation failed: ${response.status} ${text}`);
}
}
async setTicketFields(ticketId: number, fields: Record<string, unknown>): Promise<void> {
const response = await fetch(`${ZAMMAD_URL}/api/v1/tickets/${ticketId}`, {
method: 'PUT',
headers: {
'Authorization': `Token token=${ZAMMAD_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(fields),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Zammad ticket fields update failed: ${response.status} ${text}`);
}
}
async upsertSharedDraft(ticketId: number, body: string, recipients: { to: string; cc?: string }): Promise<void> {
const response = await fetch(`${ZAMMAD_URL}/api/v1/tickets/${ticketId}/shared_draft`, {
method: 'PUT',
headers: {
'Authorization': `Token token=${ZAMMAD_TOKEN}`,
'Content-Type': 'application/json',
},
// TicketSharedDraftZoomController#draft_params only permits nested "new_article" /
// "ticket_attributes" keys (params.permit ticket_attributes: {}, new_article: {}) —
// top-level body/type/internal are silently stripped by Rails strong params, which
// creates/updates the draft with an empty article and no error at all.
body: JSON.stringify({
new_article: { body, type: 'email', internal: false, to: recipients.to, cc: recipients.cc ?? '' },
ticket_attributes: {},
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Zammad shared draft update failed: ${response.status} ${text}`);
}
}
}