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
+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) };
}