row (name, type, code, * description, price, frequency, seat source, tax id). Not archived, at most * $limit, by name. */ function aiBillingCatalog($limit = 200) { global $mysqli; $catalog = []; $sql = mysqli_query($mysqli, "SELECT product_id, product_name, product_type, product_code, product_description, product_price, product_frequency, product_seat_source, product_tax_id FROM products WHERE product_archived_at IS NULL ORDER BY product_name ASC LIMIT " . max(1, intval($limit))); while ($row = mysqli_fetch_assoc($sql)) { $catalog[intval($row['product_id'])] = $row; } return $catalog; } /* The catalog as a model reads it - one line each: id | name | service or product | how it is billed | price | what it is. */ function aiBillingCatalogText(array $catalog, $currency) { $billed = ['month' => 'monthly', 'quarter' => 'quarterly', 'year' => 'yearly']; $per = ['contacts' => ' per user', 'assets' => ' per device']; $lines = []; foreach ($catalog as $id => $row) { $description = aiHtmlToText((string) $row['product_description'], 120); $lines[] = $id . ' | ' . $row['product_name'] . ($row['product_code'] ? " ({$row['product_code']})" : '') . ' | ' . $row['product_type'] . ' | ' . ($billed[(string) $row['product_frequency']] ?? 'once') . ($per[(string) $row['product_seat_source']] ?? '') . ' | ' . billingMoney($row['product_price'], $currency) . ($description !== '' ? ' | ' . str_replace("\n", ' ', $description) : ''); } return implode("\n", $lines); } /* ------------------------------------------------------------- A ticket's time, described */ /* * A ticket's work as a model reads it, for its invoice line - what was asked, * then each reply and note in order, saying who it is from and the time it * logged, then the tasks. The latest of a long one, to about 30,000 * characters. Null when the ticket is gone. */ function aiTicketWorkText($ticket_id) { global $mysqli; $ticket_id = intval($ticket_id); $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_details, ticket_created_at, ticket_client_id, client_name FROM tickets LEFT JOIN clients ON client_id = ticket_client_id WHERE ticket_id = $ticket_id")); if (!$row) { return null; } $lines = ['Ticket: ' . ticketReference($row) . ' - ' . $row['ticket_subject'], 'Client: ' . ($row['client_name'] ?: 'none'), "\nWhat was asked:\n" . aiHtmlToText($row['ticket_details'], 6000)]; $replies = []; $seconds = 0; $sql = mysqli_query($mysqli, "SELECT ticket_reply, ticket_reply_type, ticket_reply_created_at, TIME_TO_SEC(ticket_reply_time_worked) AS seconds FROM ticket_replies WHERE ticket_reply_ticket_id = $ticket_id AND ticket_reply_archived_at IS NULL ORDER BY ticket_reply_id ASC"); while ($reply = mysqli_fetch_assoc($sql)) { $worked = intval($reply['seconds']); $seconds += $worked; $who = ['Client' => 'The client', 'Internal' => 'Internal note'][$reply['ticket_reply_type']] ?? 'Reply to the client'; $replies[] = '[' . date('M j, Y', strtotime($reply['ticket_reply_created_at'])) . "] $who" . ($worked ? ' - ' . billingDuration($worked) . ' logged' : '') . ":\n" . aiHtmlToText($reply['ticket_reply'], 3000); } $kept = []; $room = 30000; foreach (array_reverse($replies) as $reply) { if (mb_strlen($reply) > $room) { break; } $room -= mb_strlen($reply); array_unshift($kept, $reply); } $lines[] = $kept ? "\nThe work, in order:\n" . implode("\n\n", $kept) : "\nNo replies or notes on it."; if ($seconds) { $lines[] = "\nTime logged in all: " . billingDuration($seconds); } $tasks = []; $sql = mysqli_query($mysqli, "SELECT task_name FROM tasks WHERE task_ticket_id = $ticket_id AND task_completed_at IS NOT NULL ORDER BY task_order, task_id"); while ($task = mysqli_fetch_assoc($sql)) { $tasks[] = '- ' . $task['task_name']; } if ($tasks) { $lines[] = "\nTasks done:\n" . implode("\n", $tasks); } return ['text' => implode("\n", $lines), 'row' => $row]; } /* * What a model wrote as lines of plain text, tidied - no HTML, code fences or * bold, each line a "- " point, at most $max of them. */ function aiBillingPoints($content, $max = 6) { $text = aiHtmlToText(preg_replace('/^```[a-z]*\s*|\s*```$/im', '', (string) $content)); $points = []; foreach (preg_split('/\n+/', $text) as $line) { $line = trim(str_replace(['**', '__'], '', $line)); $line = trim(preg_replace('/^(?:[-*•–]|\d+[.)])\s*/u', '', $line)); if ($line === '' || preg_match('/:$/', $line)) { continue; } $points[] = '- ' . mb_substr($line, 0, 200); if (count($points) >= $max) { break; } } return $points; } /* * The invoice line description for a ticket's time - ['ok', 'text'] or ['ok' * => false, 'error']. The usual first line (billingTimeDescription() - the * ticket, its subject and date), then the work done as short points, for the * client. Passwords and keys are never sent, and never come back. */ function aiTicketInvoiceDescription($ticket_id) { $model = getAiModel('Billing'); if (!$model) { return ['ok' => false, 'error' => aiModelMissingError('Billing')]; } $work = aiTicketWorkText($ticket_id); if (!$work) { return ['ok' => false, 'error' => 'That ticket no longer exists.']; } $model['ai_provider_redact'] = 1; $result = callAiApi($model, [ ['role' => 'system', 'content' => 'You write the description on an invoice line that bills the time worked on an IT support ticket, for the client who pays it.' . ' From the ticket, say what was done: one to five short lines, each starting with "- ", in plain past tense (like "- Replaced the failed switch in the server room"), the work that took the time first.' . ' No times, dates, prices or the names of the people who did it. Nothing from internal notes the client shouldn\'t read - passwords, guesses, blame or remarks about the client - and never the placeholders like [secret-1].' . ' Plain text only: no introduction, no headings, nothing after the lines.'], ['role' => 'user', 'content' => $work['text']], ], ['feature' => 'invoice_description', 'client_id' => intval($work['row']['ticket_client_id']), 'secrets' => 'remove']); if (!$result['ok']) { return $result; } $points = aiBillingPoints($result['content'], 6); if (!$points) { return ['ok' => false, 'error' => 'The AI provider answered with nothing to use - try again.']; } return ['ok' => true, 'text' => billingTimeDescription($work['row']) . "\n" . implode("\n", $points)]; } /* ------------------------------------------------------------- A quote or invoice, written */ /* The ticket a quote was raised for - the row, or null. Only quotes have one to draw on. */ function aiBillingTicket($type, $doc_id) { global $mysqli; if ($type !== 'quote') { return null; } $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_subject FROM tickets WHERE ticket_quote_id = " . intval($doc_id) . " AND ticket_archived_at IS NULL LIMIT 1")); return $row ?: null; } /* How a line the model wrote is billed - '' (once), month, quarter or year. */ function aiBillingFrequency($value) { $value = strtolower(trim((string) $value)); $value = ['monthly' => 'month', 'quarterly' => 'quarter', 'yearly' => 'year', 'annually' => 'year', 'annual' => 'year'][$value] ?? $value; return quoteFrequency($value); } /* * One line the model wrote, made safe to show - ['kind' => catalog, labor or * other, 'product_id', 'name', 'description', 'qty', 'frequency', 'price' (null * when there is none to give it)], or null when it has no name. A catalog line * takes the product's name and price, and how the product is billed when it * says; a line named exactly as a product is that product. Labor is hours at * the rate. Anything else has no price - the model's own is never taken. */ function aiBillingLine($type, $line, array $catalog, $rate) { if (!is_array($line)) { return null; } $text = function ($key) use ($line) { return is_scalar($line[$key] ?? null) ? trim(strip_tags((string) $line[$key])) : ''; }; $name = $text('name'); $description = $text('description'); $product_id = is_scalar($line['product_id'] ?? null) ? intval($line['product_id']) : 0; if (!isset($catalog[$product_id])) { $product_id = 0; foreach ($catalog as $id => $product) { if ($name !== '' && mb_strtolower(trim($product['product_name'])) === mb_strtolower($name)) { $product_id = $id; break; } } } $labor = !$product_id && filter_var($line['labor'] ?? false, FILTER_VALIDATE_BOOLEAN); $qty = is_numeric($line['qty'] ?? null) ? round(floatval($line['qty']), 2) : 1.0; $qty = $qty > 0 ? min($qty, 99999) : 1.0; $frequency = $type === 'quote' ? aiBillingFrequency($text('billed')) : ''; if ($product_id) { $product = $catalog[$product_id]; $out = ['kind' => 'catalog', 'product_id' => $product_id, 'name' => (string) $product['product_name'], 'description' => $description !== '' ? $description : aiHtmlToText((string) $product['product_description'], 1000), 'price' => round(floatval($product['product_price']), 2)]; if ($type === 'quote' && quoteFrequency($product['product_frequency']) !== '') { $frequency = quoteFrequency($product['product_frequency']); } } elseif ($labor) { $out = ['kind' => 'labor', 'product_id' => 0, 'name' => $name !== '' ? $name : billingTimeRules()['item_name'], 'description' => $description, 'price' => $rate > 0 ? round($rate, 2) : null]; $qty = round(ceil($qty * 4) / 4, 2); } else { if ($name === '') { return null; } $out = ['kind' => 'other', 'product_id' => 0, 'name' => $name, 'description' => $description, 'price' => null]; } $out['name'] = mb_substr($out['name'], 0, 200); $out['description'] = mb_substr($out['description'], 0, 1000); $out['qty'] = $qty; $out['frequency'] = $frequency; return $out; } /* * A quote or invoice written from a line on what it is for - ['ok', 'scope', * 'lines' => aiBillingLine()s, 'note' => for the client, 'checks' => what the * agent should confirm, 'rate', 'ticket' => its reference when it was used] or * ['ok' => false, 'error']. $row is the document (billingRow()); with * $use_ticket, the ticket a quote was raised for goes with it, and with * $use_client, what ITFlow knows of the client (its people, sites and * assets - for sizing per-user and per-device lines). The ticket and the * client's documentation only for an agent with Support; without it, just * how many people the client has. */ function aiBillingDraft($type, array $row, $prompt, $use_ticket = true, $use_client = true) { global $mysqli; $model = getAiModel('Billing'); if (!$model) { return ['ok' => false, 'error' => aiModelMissingError('Billing')]; } $prompt = trim((string) $prompt); if ($prompt === '') { return ['ok' => false, 'error' => 'Say what it is for first.']; } $t = billingType($type); $c = $t['col']; $doc_id = intval($row["{$c}_id"]); $client_id = intval($row['client_id']); $currency = (string) $row["{$c}_currency_code"]; $rate = contractClientRate($client_id)['rate']; $catalog = aiBillingCatalog(); // The client's documentation and its tickets go only for an agent who may read them $support = lookupUserPermission('module_support') >= 1; $profile = $use_client && $support ? aiClientProfile($client_id) : ''; $facts = [$profile !== '' ? $profile : 'Client: ' . $row['client_name']]; if ($use_client) { $facts[] = 'People (contacts): ' . intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM contacts WHERE contact_client_id = $client_id AND contact_archived_at IS NULL"))[0]); } $facts[] = 'Labor is billed by the hour' . ($rate > 0 ? ', at ' . billingMoney($rate, $currency) : '') . '.'; $ticket = $use_ticket && $support ? aiBillingTicket($type, $doc_id) : null; $ticket_text = ''; if ($ticket) { $context = aiTicketContext(intval($ticket['ticket_id'])); $ticket_text = $context ? "\n\nThe ticket it is for (internal notes are for you only - never quote them):\n" . $context['text'] : ''; } $existing = []; foreach (billingItems($type, $doc_id) as $item) { $existing[] = '- ' . $item['item_name'] . ' x ' . billingQuantity($item['item_quantity']); } $quote = $type === 'quote'; $result = callAiApi($model, [ ['role' => 'system', 'content' => 'You write ' . ($quote ? 'quotes' : 'invoices') . ' for a managed service provider - the lines a client reads and pays for.' . ' From what the agent says it is for, choose the lines: products and services from the catalog given, by their id, wherever one fits; labor in hours for the work to do it; and anything else needed that isn\'t in the catalog.' . ' Never write a price - prices come from the catalog and the hourly rate. Be realistic about quantities and hours, and size per-user and per-device lines from the client\'s numbers when they are given.' . ' Line descriptions are for the client: a plain sentence or two on what it is, or what the work covers.' . ($quote ? ' Say how each line is billed: once, or each month, quarter or year - licenses and managed services are usually monthly, hardware and project labor once.' : ' Everything on an invoice is billed once.') . ' Answer with JSON only, in this shape: {"scope": "a short line on what it is for, under 80 characters", "lines": [{"product_id": a catalog id or null, "labor": true for hours of work, "name": "the line\'s name", "description": "for the client", "qty": a number - hours for labor' . ($quote ? ', "billed": "once" or "month" or "quarter" or "year"' : '') . '}], "note": "for the client: what is assumed and what is not included, as short lines starting with - ", "checks": ["for the agent: what to confirm before sending - quantities you guessed, what the catalog didn\'t have"]}. At most 20 lines.'], ['role' => 'user', 'content' => "What it is for: $prompt\n\n" . implode("\n", $facts) . ($existing ? "\n\nOn it already (don't repeat these):\n" . implode("\n", $existing) : '') . $ticket_text . "\n\nThe catalog (id | name | type | billed | price | what it is):\n" . ($catalog ? aiBillingCatalogText($catalog, $currency) : 'Empty - nothing is in the catalog.')], ], ['feature' => 'billing_draft', 'client_id' => $client_id, 'secrets' => 'remove']); if (!$result['ok']) { return $result; } $answer = aiJsonFromText($result['content']); if (!$answer || !is_array($answer['lines'] ?? null)) { return ['ok' => false, 'error' => "The AI provider's answer couldn't be read - try again."]; } $lines = []; foreach (array_slice($answer['lines'], 0, 20) as $line) { $line = aiBillingLine($type, $line, $catalog, $rate); if ($line) { $lines[] = $line; } } if (!$lines) { return ['ok' => false, 'error' => 'The AI provider answered with no lines to use - say a little more about what it is for.']; } $checks = []; foreach (array_slice((array) ($answer['checks'] ?? []), 0, 6) as $check) { $check = trim(strip_tags(is_scalar($check) ? (string) $check : '')); if ($check !== '') { $checks[] = mb_substr($check, 0, 300); } } $note = is_scalar($answer['note'] ?? null) ? implode("\n", aiBillingPoints((string) $answer['note'], 10)) : ''; return [ 'ok' => true, 'scope' => mb_substr(trim(strip_tags(is_scalar($answer['scope'] ?? null) ? (string) $answer['scope'] : '')), 0, 255), 'lines' => $lines, 'note' => mb_substr($note, 0, 2000), 'checks' => $checks, 'rate' => $rate, 'ticket' => $ticket ? ticketReference($ticket) : '', ]; }