]*>.*?#is', '', $html); $html = preg_replace('##i', "\n", $html); $html = preg_replace('#]*>#i', "\n- ", $html); $html = preg_replace('##i', "\n", $html); $text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $text = str_replace("\xC2\xA0", ' ', $text); $text = preg_replace("/[ \t]+/", ' ', $text); $text = preg_replace("/ *\n */", "\n", $text); $text = trim(preg_replace("/\n{3,}/", "\n\n", $text)); if ($limit && mb_strlen($text) > $limit) { $text = rtrim(mb_substr($text, 0, $limit)) . ' [...]'; } return $text; } /* * A ticket as a model reads it - ['text' => it in words, 'row' => the ticket, * 'first_name' => its contact's first name, 'client_id']. Its reference, * subject, client, contact, status, priority, category, source, agent, assets, * tasks and time worked, what was asked, then the conversation in order, each * reply saying who it is from and whether the client saw it. Internal notes * only with $internal. Long conversations keep the first message and the * latest replies, to about 40,000 characters. */ function aiTicketContext($ticket_id, $internal = true) { global $mysqli; $ticket_id = intval($ticket_id); $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT tickets.*, client_name, contact_name, contact_title, ticket_status_name, category_name, user_name, asset_name, asset_type FROM tickets LEFT JOIN clients ON client_id = ticket_client_id LEFT JOIN contacts ON contact_id = ticket_contact_id LEFT JOIN ticket_statuses ON ticket_status_id = ticket_status LEFT JOIN categories ON category_id = ticket_category LEFT JOIN users ON user_id = ticket_assigned_to LEFT JOIN assets ON asset_id = ticket_asset_id WHERE ticket_id = $ticket_id")); if (!$row) { return null; } $lines = [ 'Ticket: ' . ticketReference($row) . ' - ' . $row['ticket_subject'], 'Client: ' . ($row['client_name'] ?: 'none - internal work'), 'Contact: ' . ($row['contact_name'] ? $row['contact_name'] . ($row['contact_title'] ? ', ' . $row['contact_title'] : '') : 'none'), 'Status: ' . $row['ticket_status_name'] . ' - Priority: ' . $row['ticket_priority'] . ' - Category: ' . ($row['category_name'] ?: 'none'), 'Raised: ' . date('M j, Y g:i A', strtotime($row['ticket_created_at'])) . ' from ' . ($row['ticket_source'] ?: 'an agent'), 'Assigned to: ' . ($row['user_name'] ?: 'nobody yet'), ]; $assets = []; if ($row['asset_name']) { $assets[] = $row['asset_name'] . ($row['asset_type'] ? " ({$row['asset_type']})" : ''); } $sql = mysqli_query($mysqli, "SELECT asset_name, asset_type FROM ticket_assets JOIN assets USING (asset_id) WHERE ticket_assets.ticket_id = $ticket_id ORDER BY asset_name"); while ($asset = mysqli_fetch_assoc($sql)) { $assets[] = $asset['asset_name'] . ($asset['asset_type'] ? " ({$asset['asset_type']})" : ''); } if ($assets) { $lines[] = 'Assets: ' . implode(', ', array_unique($assets)); } $tasks = []; $sql = mysqli_query($mysqli, "SELECT task_name, task_completed_at FROM tasks WHERE task_ticket_id = $ticket_id ORDER BY task_order, task_id"); while ($task = mysqli_fetch_assoc($sql)) { $tasks[] = '- [' . ($task['task_completed_at'] ? 'done' : 'open') . '] ' . $task['task_name']; } if ($tasks) { $lines[] = "Tasks:\n" . implode("\n", $tasks); } $seconds = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(SUM(TIME_TO_SEC(ticket_reply_time_worked)), 0) FROM ticket_replies WHERE ticket_reply_ticket_id = $ticket_id AND ticket_reply_archived_at IS NULL"))[0]); if ($seconds) { $lines[] = 'Time worked so far: ' . floor($seconds / 3600) . 'h ' . floor(($seconds % 3600) / 60) . 'm'; } $lines[] = "\nWhat was asked:\n" . aiHtmlToText($row['ticket_details'], 8000); // The conversation - who each reply is from, and whether the client saw it $replies = []; $sql = mysqli_query($mysqli, "SELECT ticket_reply, ticket_reply_type, ticket_reply_created_at, user_name, contact_name FROM ticket_replies LEFT JOIN users ON ticket_reply_type <> 'Client' AND user_id = ticket_reply_by LEFT JOIN contacts ON ticket_reply_type = 'Client' AND contact_id = ticket_reply_by WHERE ticket_reply_ticket_id = $ticket_id AND ticket_reply_archived_at IS NULL" . ($internal ? '' : " AND ticket_reply_type <> 'Internal'") . " ORDER BY ticket_reply_id ASC"); while ($reply = mysqli_fetch_assoc($sql)) { $type = $reply['ticket_reply_type']; $who = $type === 'Client' ? 'Client - ' . ($reply['contact_name'] ?: 'the client') : ($type === 'Internal' ? 'Internal note - ' : 'Agent reply - ') . ($reply['user_name'] ?: 'ITFlow'); $replies[] = '[' . date('M j, Y g:i A', strtotime($reply['ticket_reply_created_at'])) . "] $who:\n" . aiHtmlToText($reply['ticket_reply'], 4000); } // Room for the latest of a long one $kept = []; $room = 30000; foreach (array_reverse($replies) as $reply) { if (mb_strlen($reply) > $room) { break; } $room -= mb_strlen($reply); array_unshift($kept, $reply); } if ($kept) { $lines[] = "\nThe conversation" . (count($kept) < count($replies) ? ' (the latest ' . count($kept) . ' of ' . count($replies) . ' replies)' : '') . ":\n" . implode("\n\n", $kept); } else { $lines[] = "\nNo replies yet."; } $first_name = trim(strtok((string) $row['contact_name'], ' ') ?: ''); return ['text' => implode("\n", $lines), 'row' => $row, 'first_name' => $first_name, 'client_id' => intval($row['ticket_client_id'])]; } /* * A ticket summed up for the agents working it - ['ok', 'html'] or ['ok' => * false, 'error']. In short, then the problem, what has been done, where it * stands and what is next. Internal notes included - it is for the team. */ function aiTicketSummary($ticket_id) { $model = getAiModel('Tickets'); if (!$model) { return ['ok' => false, 'error' => aiModelMissingError('Tickets')]; } $ticket = aiTicketContext($ticket_id, true); if (!$ticket) { return ['ok' => false, 'error' => 'That ticket no longer exists.']; } $result = callAiApi($model, [ ['role' => 'system', 'content' => 'You summarize IT support tickets for the agents of a managed service provider, so a colleague can pick a ticket up cold. Use only what the ticket says - never guess at causes, steps or dates it doesn\'t give.' . ' Answer in HTML only, in exactly this shape:

In short: one sentence on what the client needs and where it stands.

' . '
The problem
What\'s been done
(who did what, with dates where they help)
Where it stands
(the status, who it is waiting on, open tasks)' . '
Next steps
. Say when something is urgent or has been escalated, when files or screenshots were sent, and what is unclear and needs asking.' . ' Short bullets. No introduction, no code fences, no or .'], ['role' => 'user', 'content' => $ticket['text']], ], ['feature' => 'ticket_summary', 'client_id' => $ticket['client_id']]); if (!$result['ok']) { return $result; } $html = aiCleanHtml($result['content']); return $html === '' ? ['ok' => false, 'error' => 'The AI provider answered with nothing - try again.'] : ['ok' => true, 'html' => $html]; } /* * The next reply on a ticket, drafted - ['ok', 'html'] or ['ok' => false, * 'error']. $notes is what the agent has typed in the editor so far: the reply * says what it says, in full. $type is who reads it: 'internal', 'public', * 'email' or 'text' - a reply the client reads is drafted without the internal * notes, so nothing in them can find its way to the client, an email has no * sign-off (the agent's signature is added when it is sent), and a text is a * few short plain sentences. */ function aiTicketDraft($ticket_id, $notes = '', $type = 'email') { $model = getAiModel('Tickets'); if (!$model) { return ['ok' => false, 'error' => aiModelMissingError('Tickets')]; } $type = in_array($type, ['internal', 'public', 'email', 'text'], true) ? $type : 'email'; $ticket = aiTicketContext($ticket_id, $type === 'internal'); if (!$ticket) { return ['ok' => false, 'error' => 'That ticket no longer exists.']; } $notes = aiHtmlToText($notes, 6000); if ($type === 'text') { $instructions = 'You draft the text messages the agents of a managed service provider send to their clients about support tickets.' . ' One to three short, plain, friendly sentences - it is read on a phone - with no jargon, no formatting, no greeting beyond' . ($ticket['first_name'] !== '' ? " \"Hi {$ticket['first_name']},\"" : ' a short "Hi,"') . ' and no sign-off.'; } elseif ($type === 'internal') { $instructions = 'You write internal notes on IT support tickets for the agents of a managed service provider - for colleagues, not the client: concise and technical, no greeting and no sign-off.'; } else { $instructions = 'You draft the replies the agents of a managed service provider send to their clients on support tickets.' . ' Plain, friendly and professional, in short paragraphs, with no jargon the client wouldn\'t know.' . ($ticket['first_name'] !== '' ? " Greet {$ticket['first_name']} by first name." : ' Greet the client briefly.') . ($type === 'email' ? ' End with a short friendly closing line but no name or signature - the agent\'s signature is added when it is sent.' : ' End with a short friendly closing line and no signature.'); } $instructions .= $notes !== '' ? ' The agent has written notes on what to say: write the reply from them - everything they say, in full sentences, and nothing they don\'t: no extra promises, times, causes or fixes.' : ' Write the next sensible reply from what the ticket says: acknowledge what is new, say what is being done, or ask for what is missing.'; $instructions .= ' Never make up facts, steps already taken, times or commitments. Where only the agent knows something the reply needs, put a short note in square brackets for them to fill in, like [time of the visit].' . ' Write in the language the client writes in. Answer with the reply in HTML only (

,