), its * summary (the

after it), what it says]. The prompts ask for both at * the top; either may be missing. */ function aiDocumentSplit($html) { $html = trim((string) $html); $title = ''; if (preg_match('#]*>(.*?)#is', $html, $m)) { $title = trim(html_entity_decode(strip_tags($m[1]), ENT_QUOTES, 'UTF-8')); $html = trim(preg_replace('#]*>.*?#is', '', $html, 1)); } $description = ''; if (preg_match('#^]*>\s*(.*?)\s*

#is', $html, $m)) { $description = trim(html_entity_decode(strip_tags($m[1]), ENT_QUOTES, 'UTF-8')); $html = trim(substr($html, strlen($m[0]))); } return [mb_substr($title, 0, 200), mb_substr($description, 0, 250), $html]; } /* * What ITFlow knows of a client that documentation draws on, in words - who * they are, their sites, the people to contact, their servers and network * gear, networks, software and domains. Never a password, a license key or a * login. For the agent who can see the client's documentation. */ function aiClientProfile($client_id) { global $mysqli; $client_id = intval($client_id); $client = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT client_name, client_type, client_website FROM clients WHERE client_id = $client_id")); if (!$client) { return ''; } $lines = ['Client: ' . $client['client_name'] . ($client['client_type'] ? " ({$client['client_type']})" : '') . ($client['client_website'] ? " - {$client['client_website']}" : '')]; $list = function ($label, $sql, $format) use ($mysqli, &$lines) { $items = []; $result = mysqli_query($mysqli, $sql); while ($row = mysqli_fetch_assoc($result)) { $items[] = '- ' . $format($row); } if ($items) { $lines[] = "$label:\n" . implode("\n", $items); } }; $list('Sites', "SELECT location_name, location_city, location_state FROM locations WHERE location_client_id = $client_id AND location_archived_at IS NULL ORDER BY location_primary DESC, location_name LIMIT 10", function ($r) { return $r['location_name'] . (trim($r['location_city'] . ' ' . $r['location_state']) !== '' ? ' - ' . trim($r['location_city'] . ', ' . $r['location_state'], ', ') : ''); }); $list('People', "SELECT contact_name, contact_title, contact_primary, contact_technical, contact_billing FROM contacts WHERE contact_client_id = $client_id AND contact_archived_at IS NULL AND (contact_primary = 1 OR contact_technical = 1 OR contact_billing = 1 OR contact_important = 1) ORDER BY contact_primary DESC, contact_name LIMIT 8", function ($r) { $roles = array_keys(array_filter(['primary contact' => $r['contact_primary'], 'technical contact' => $r['contact_technical'], 'billing contact' => $r['contact_billing']])); return $r['contact_name'] . ($r['contact_title'] ? ", {$r['contact_title']}" : '') . ($roles ? ' (' . implode(', ', $roles) . ')' : ''); }); $counts = []; $result = mysqli_query($mysqli, "SELECT asset_type, COUNT(*) FROM assets WHERE asset_client_id = $client_id AND asset_archived_at IS NULL GROUP BY asset_type ORDER BY COUNT(*) DESC"); while ($row = mysqli_fetch_row($result)) { $counts[] = "$row[1] " . ($row[0] ?: 'other'); } if ($counts) { $lines[] = 'Assets: ' . implode(', ', $counts); } $list('Servers and network gear', "SELECT asset_name, asset_type, asset_make, asset_model, asset_os FROM assets WHERE asset_client_id = $client_id AND asset_archived_at IS NULL AND (" . assetTypeGroupSql('server') . " OR " . assetTypeGroupSql('virtual') . " OR " . assetTypeGroupSql('network') . " OR " . assetTypeGroupSql('power') . ") ORDER BY FIELD(asset_type, 'Firewall/Router', 'Server', 'NAS', 'Virtual Machine', 'Switch', 'Access Point'), asset_name LIMIT 25", function ($r) { return $r['asset_name'] . ' - ' . $r['asset_type'] . (trim($r['asset_make'] . ' ' . $r['asset_model']) !== '' ? ', ' . trim($r['asset_make'] . ' ' . $r['asset_model']) : '') . ($r['asset_os'] ? ", {$r['asset_os']}" : ''); }); $list('Networks', "SELECT network_name, network_type, network_vlan, network, network_gateway FROM networks WHERE network_client_id = $client_id AND network_archived_at IS NULL ORDER BY network_name LIMIT 15", function ($r) { return $r['network_name'] . ($r['network_type'] ? " ({$r['network_type']})" : '') . ($r['network'] ? " - {$r['network']}" : '') . ($r['network_vlan'] ? ", VLAN {$r['network_vlan']}" : '') . ($r['network_gateway'] ? ", gateway {$r['network_gateway']}" : ''); }); $list('Software and licenses', "SELECT software_name, software_version, software_type FROM software WHERE software_client_id = $client_id AND software_archived_at IS NULL ORDER BY software_name LIMIT 25", function ($r) { return $r['software_name'] . ($r['software_version'] ? " {$r['software_version']}" : '') . ($r['software_type'] ? " ({$r['software_type']})" : ''); }); $list('Domains', "SELECT domain_name FROM domains WHERE domain_client_id = $client_id AND domain_archived_at IS NULL ORDER BY domain_name LIMIT 10", function ($r) { return $r['domain_name']; }); return implode("\n", $lines); } /* * A new document drafted - ['ok', 'title', 'description', 'html'] or ['ok' => * false, 'error']. $prompt says what it is for; with $use_client, the * client's profile goes with it, and its details are used where they belong. * Anything not known is left as a short placeholder in square brackets. */ function aiDocumentDraft($client_id, $prompt, $use_client = true) { $model = getAiModel('Documentation'); if (!$model) { return ['ok' => false, 'error' => aiModelMissingError('Documentation')]; } $prompt = trim((string) $prompt); if ($prompt === '') { return ['ok' => false, 'error' => 'Say what the document is for first.']; } $profile = $use_client ? aiClientProfile($client_id) : ''; $result = callAiApi($model, [ ['role' => 'system', 'content' => 'You write IT documentation for a managed service provider - for its engineers, about one of its clients. Write the document asked for: clear, practical and specific, with headings, numbered steps for anything to do, and tables where they help.' . ($profile !== '' ? ' Use the client\'s details given where they belong - their sites, people, servers, networks and software - and never invent ones that aren\'t given.' : '') . ' Where something only the engineer knows is needed, leave a short placeholder in square brackets, like [Backup retention] or [ISP account number]. Never write a password, key or login.' . ' Answer in HTML only, in this shape:

a short title

one sentence on what it covers

then the document. No introduction, no code fences, no or .'], ['role' => 'user', 'content' => "Write: $prompt" . ($profile !== '' ? "\n\nWhat we know about the client:\n$profile" : '')], ], ['feature' => 'document_draft', 'client_id' => intval($client_id), 'secrets' => 'remove']); if (!$result['ok']) { return $result; } [$title, $description, $html] = aiDocumentSplit(aiCleanHtml($result['content'])); if (trim(strip_tags($html)) === '') { return ['ok' => false, 'error' => 'The AI provider answered with nothing to keep - try again.']; } return ['ok' => true, 'title' => $title, 'description' => $description, 'html' => $html]; } /* * A document reviewed - ['ok', 'verdict' => good / needs work / out of date, * 'summary' => a one-line summary for it, 'findings' => [severity, text] - * high first, 'secrets' => how many passwords and keys are written in it] or * ['ok' => false, 'error']. The passwords and keys are found here, not by the * model, and never sent - whatever the provider is set to. */ function aiDocumentReview($document_id) { global $mysqli; $model = getAiModel('Documentation'); if (!$model) { return ['ok' => false, 'error' => aiModelMissingError('Documentation')]; } $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT document_name, document_description, document_content, document_client_id, document_updated_at, client_name FROM documents LEFT JOIN clients ON client_id = document_client_id WHERE document_id = " . intval($document_id))); if (!$row) { return ['ok' => false, 'error' => 'That document no longer exists.']; } $text = aiHtmlToText($row['document_content'], 30000); if ($text === '') { return ['ok' => false, 'error' => 'There is nothing in it to review yet.']; } [, $secrets] = aiRedact($text); $model['ai_provider_redact'] = 1; $result = callAiApi($model, [ ['role' => 'system', 'content' => 'You review a managed service provider\'s IT documentation about one of its clients, as a careful senior engineer would. Today is ' . date('F j, Y') . '.' . ' List what should be fixed or checked: steps that are unclear, missing or in the wrong order; anything that looks out of date (software or systems at or past end of life, old dates, old names); things that contradict each other; what a document like this usually has that this one lacks (how to restore, who to call, where things are kept); and anything risky to follow as written.' . ' Don\'t mention the placeholders like [secret-1] - they stand for passwords taken out before you saw it.' . ' Answer with JSON only, in this shape: {"verdict": "good" or "needs work" or "out of date", "summary": "one sentence on what the document covers, for its summary", "findings": [{"severity": "high" or "medium" or "low", "text": "what to fix or check, in one or two sentences"}]}. At most 10 findings, the most important first; none when it is good.'], ['role' => 'user', 'content' => 'Document: ' . $row['document_name'] . ($row['client_name'] ? "\nClient: {$row['client_name']}" : '') . "\nLast changed: " . date('F j, Y', strtotime($row['document_updated_at'] ?: 'now')) . "\n\n$text"], ], ['feature' => 'document_review', 'client_id' => intval($row['document_client_id'])]); if (!$result['ok']) { return $result; } $answer = aiJsonFromText($result['content']); if (!$answer) { return ['ok' => false, 'error' => "The AI provider's answer couldn't be read - try again."]; } $order = ['high' => 0, 'medium' => 1, 'low' => 2]; $findings = []; foreach (array_slice((array) ($answer['findings'] ?? []), 0, 10) as $finding) { $text = is_array($finding) ? trim(strip_tags((string) ($finding['text'] ?? ''))) : trim(strip_tags((string) $finding)); if ($text === '') { continue; } $severity = strtolower((string) (is_array($finding) ? ($finding['severity'] ?? 'medium') : 'medium')); $findings[] = [isset($order[$severity]) ? $severity : 'medium', mb_substr($text, 0, 500)]; } usort($findings, function ($a, $b) use ($order) { return $order[$a[0]] <=> $order[$b[0]]; }); $verdict = strtolower(trim((string) ($answer['verdict'] ?? ''))); return [ 'ok' => true, 'verdict' => in_array($verdict, ['good', 'needs work', 'out of date'], true) ? $verdict : ($findings ? 'needs work' : 'good'), 'summary' => mb_substr(trim(strip_tags((string) ($answer['summary'] ?? ''))), 0, 250), 'findings' => $findings, 'secrets' => count($secrets), ]; }