PHP Classes

File: scripts/echomail_summary.php

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   scripts/echomail_summary.php   Download  
File: scripts/echomail_summary.php
Role: Example script
Content type: text/plain
Description: Example script
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 18,252 bytes
 

Contents

Class file image Download
#!/usr/bin/env php <?php /** * echomail_summary.php - Summarize echomail traffic over the past N days, * optionally using the AI provider to generate a narrative digest. * * Usage: php scripts/echomail_summary.php [options] * --days=N Days to look back (default: 7) * --top=N Rows in ranked lists (default: 10) * --area=TAG Limit to a single echo area * --no-ai Print raw stats table only, skip AI summary * --output=FILE Write AI summary to FILE instead of stdout * --help */ require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../src/functions.php'; use BinktermPHP\Database; use BinktermPHP\AI\AiService; use BinktermPHP\AI\AiRequest; // ?? Argument parsing ?????????????????????????????????????????????????????????? $days = 7; $top = 10; $area = null; $useAi = true; $output = null; foreach ($argv as $i => $arg) { if ($i === 0) continue; if (preg_match('/^--days=(\d+)$/', $arg, $m)) { $days = max(1, (int) $m[1]); } elseif (preg_match('/^--top=(\d+)$/', $arg, $m)) { $top = max(1, (int) $m[1]); } elseif (preg_match('/^--area=(.+)$/', $arg, $m)) { $area = strtoupper(trim($m[1])); } elseif (preg_match('/^--output=(.+)$/', $arg, $m)){ $output = trim($m[1]); } elseif ($arg === '--no-ai') { $useAi = false; } elseif ($arg === '--help' || $arg === '-h') { echo "Usage: php scripts/echomail_summary.php [options]\n\n"; echo " --days=N Days to look back (default: 7)\n"; echo " --top=N Rows in ranked lists (default: 10)\n"; echo " --area=TAG Limit to a single echo area tag\n"; echo " --no-ai Print raw stats table only, skip AI summary\n"; echo " --output=FILE Write AI summary to FILE instead of stdout\n"; exit(0); } } try { $db = Database::getInstance()->getPdo(); } catch (Exception $e) { fwrite(STDERR, "Database connection failed: " . $e->getMessage() . "\n"); exit(1); } $since = (new DateTimeImmutable('now', new DateTimeZone('UTC'))) ->modify("-{$days} days") ->format('Y-m-d H:i:s'); $areaFilter = 'AND ea.is_sysop_only = FALSE' . ($area !== null ? ' AND ea.tag = :area' : ''); // ?? Helper: bind common params ???????????????????????????????????????????????? $bind = function (PDOStatement $stmt) use ($since, $area): void { $stmt->bindValue(':since', $since); if ($area !== null) $stmt->bindValue(':area', $area); }; // ?? Query: totals ????????????????????????????????????????????????????????????? $stmt = $db->prepare(" SELECT COUNT(*) AS total, COUNT(DISTINCT e.echoarea_id) AS active_areas, COUNT(DISTINCT ea.domain) AS networks, COUNT(DISTINCT e.from_address) AS unique_nodes, COUNT(DISTINCT e.from_name) AS unique_names FROM echomail e JOIN echoareas ea ON ea.id = e.echoarea_id WHERE e.date_received >= :since {$areaFilter} "); $bind($stmt); $stmt->execute(); $totals = $stmt->fetch(PDO::FETCH_ASSOC); // ?? Query: per-network message counts ????????????????????????????????????????? $stmt = $db->prepare(" SELECT COALESCE(ea.domain, 'unknown') AS network, COUNT(*) AS msg_count, COUNT(DISTINCT e.echoarea_id) AS area_count FROM echomail e JOIN echoareas ea ON ea.id = e.echoarea_id WHERE e.date_received >= :since {$areaFilter} GROUP BY ea.domain ORDER BY msg_count DESC "); $bind($stmt); $stmt->execute(); $networks = $stmt->fetchAll(PDO::FETCH_ASSOC); // ?? Query: per-area counts ???????????????????????????????????????????????????? $stmt = $db->prepare(" SELECT ea.tag, COALESCE(ea.domain, 'unknown') AS network, COALESCE(ea.description, '') AS description, COUNT(*) AS msg_count FROM echomail e JOIN echoareas ea ON ea.id = e.echoarea_id WHERE e.date_received >= :since {$areaFilter} GROUP BY ea.id, ea.tag, ea.domain, ea.description ORDER BY msg_count DESC LIMIT :top "); $bind($stmt); $stmt->bindValue(':top', $top, PDO::PARAM_INT); $stmt->execute(); $areaCounts = $stmt->fetchAll(PDO::FETCH_ASSOC); // ?? Query: top senders (volume) ??????????????????????????????????????????????? $stmt = $db->prepare(" SELECT e.from_name, e.from_address, COALESCE(ea.domain, 'unknown') AS network, COUNT(*) AS msg_count FROM echomail e JOIN echoareas ea ON ea.id = e.echoarea_id WHERE e.date_received >= :since {$areaFilter} GROUP BY e.from_name, e.from_address, ea.domain ORDER BY msg_count DESC LIMIT :top "); $bind($stmt); $stmt->bindValue(':top', $top, PDO::PARAM_INT); $stmt->execute(); $topSenders = $stmt->fetchAll(PDO::FETCH_ASSOC); // ?? Query: most replied-to senders ??????????????????????????????????????????? // Counts how many messages in the window are replies to each sender. $stmt = $db->prepare(" SELECT orig.from_name, orig.from_address, COUNT(*) AS reply_count FROM echomail reply JOIN echomail orig ON orig.id = reply.reply_to_id JOIN echoareas ea ON ea.id = reply.echoarea_id WHERE reply.date_received >= :since AND reply.reply_to_id IS NOT NULL {$areaFilter} GROUP BY orig.from_name, orig.from_address ORDER BY reply_count DESC LIMIT :top "); $bind($stmt); $stmt->bindValue(':top', $top, PDO::PARAM_INT); $stmt->execute(); $mostRepliedTo = $stmt->fetchAll(PDO::FETCH_ASSOC); // ?? Query: most active threads (by subject, bot-filtered) ????????????????????? $stmt = $db->prepare(" SELECT e.subject, COUNT(DISTINCT ea.domain) AS network_count, STRING_AGG(DISTINCT ea.tag, ', ' ORDER BY ea.tag) AS areas, COUNT(*) AS post_count FROM echomail e JOIN echoareas ea ON ea.id = e.echoarea_id WHERE e.date_received >= :since AND e.subject IS NOT NULL AND e.subject <> '' AND e.from_name NOT ILIKE '%bot%' AND e.from_name NOT ILIKE '%feeder%' AND e.from_name NOT ILIKE '%news%' {$areaFilter} GROUP BY e.subject ORDER BY post_count DESC LIMIT :top "); $bind($stmt); $stmt->bindValue(':top', $top, PDO::PARAM_INT); $stmt->execute(); $threads = $stmt->fetchAll(PDO::FETCH_ASSOC); // ?? Query: daily breakdown ???????????????????????????????????????????????????? $stmt = $db->prepare(" SELECT DATE(e.date_received AT TIME ZONE 'UTC') AS day, COUNT(*) AS msg_count FROM echomail e JOIN echoareas ea ON ea.id = e.echoarea_id WHERE e.date_received >= :since {$areaFilter} GROUP BY day ORDER BY day ASC "); $bind($stmt); $stmt->execute(); $daily = $stmt->fetchAll(PDO::FETCH_ASSOC); // ?? Query: cross-network threads (same subject in 2+ networks) ???????????????? $stmt = $db->prepare(" SELECT e.subject, COUNT(DISTINCT ea.domain) AS network_count, STRING_AGG(DISTINCT COALESCE(ea.domain,'unknown'), ', ' ORDER BY COALESCE(ea.domain,'unknown')) AS networks_list, COUNT(*) AS post_count FROM echomail e JOIN echoareas ea ON ea.id = e.echoarea_id WHERE e.date_received >= :since AND e.subject IS NOT NULL AND e.subject <> '' AND e.from_name NOT ILIKE '%bot%' AND e.from_name NOT ILIKE '%feeder%' {$areaFilter} GROUP BY e.subject HAVING COUNT(DISTINCT ea.domain) > 1 ORDER BY network_count DESC, post_count DESC LIMIT 10 "); $bind($stmt); $stmt->execute(); $crossNetwork = $stmt->fetchAll(PDO::FETCH_ASSOC); // ?? Raw stats table ??????????????????????????????????????????????????????????? $hr = str_repeat('?', 72); $hr2 = str_repeat('?', 72); $printRawStats = function () use ( $days, $area, $totals, $networks, $areaCounts, $topSenders, $mostRepliedTo, $threads, $daily, $crossNetwork, $hr, $hr2, $top ): void { $title = $area !== null ? "Echomail Summary ? {$area} ? past {$days} day(s)" : "Echomail Summary ? past {$days} day(s)"; echo "\n{$hr2}\n {$title}\n Generated: " . gmdate('Y-m-d H:i') . " UTC\n{$hr2}\n\n"; echo "OVERVIEW\n{$hr}\n"; echo sprintf(" Total messages : %d\n", (int)$totals['total']); echo sprintf(" Active areas : %d\n", (int)$totals['active_areas']); echo sprintf(" Networks : %d\n", (int)$totals['networks']); echo sprintf(" Unique nodes : %d\n", (int)$totals['unique_nodes']); echo sprintf(" Unique names : %d\n", (int)$totals['unique_names']); echo "\n"; if (!empty($networks)) { echo "BY NETWORK\n{$hr}\n"; echo sprintf(" %-20s %8s %8s\n", 'Network', 'Messages', 'Areas'); echo " " . str_repeat('-', 40) . "\n"; foreach ($networks as $row) { echo sprintf(" %-20s %8d %8d\n", $row['network'], $row['msg_count'], $row['area_count']); } echo "\n"; } if (!empty($daily)) { echo "DAILY TRAFFIC\n{$hr}\n"; $maxDay = max(array_column($daily, 'msg_count')); foreach ($daily as $row) { $barLen = $maxDay > 0 ? (int) round($row['msg_count'] / $maxDay * 40) : 0; echo sprintf(" %s %s %d\n", $row['day'], str_pad(str_repeat('?', $barLen), 40), $row['msg_count']); } echo "\n"; } if (!empty($areaCounts)) { echo "TOP AREAS\n{$hr}\n"; echo sprintf(" %-20s %-12s %-30s %s\n", 'Area', 'Network', 'Description', 'Msgs'); echo " " . str_repeat('-', 70) . "\n"; foreach ($areaCounts as $row) { echo sprintf(" %-20s %-12s %-30s %d\n", $row['tag'], $row['network'], mb_strimwidth($row['description'], 0, 29, '?'), $row['msg_count']); } echo "\n"; } if (!empty($topSenders)) { echo "TOP SENDERS (VOLUME)\n{$hr}\n"; echo sprintf(" %-28s %-18s %-14s %s\n", 'Name', 'Address', 'Network', 'Msgs'); echo " " . str_repeat('-', 70) . "\n"; foreach ($topSenders as $row) { echo sprintf(" %-28s %-18s %-14s %d\n", mb_strimwidth($row['from_name'], 0, 27, '?'), $row['from_address'], $row['network'], $row['msg_count']); } echo "\n"; } if (!empty($mostRepliedTo)) { echo "MOST REPLIED-TO\n{$hr}\n"; echo sprintf(" %-28s %-20s %s\n", 'Name', 'Address', 'Replies'); echo " " . str_repeat('-', 60) . "\n"; foreach ($mostRepliedTo as $row) { echo sprintf(" %-28s %-20s %d\n", mb_strimwidth($row['from_name'], 0, 27, '?'), $row['from_address'], $row['reply_count']); } echo "\n"; } if (!empty($threads)) { echo "MOST ACTIVE THREADS\n{$hr}\n"; echo sprintf(" %-38s %-20s %s\n", 'Subject', 'Areas', 'Posts'); echo " " . str_repeat('-', 68) . "\n"; foreach ($threads as $row) { echo sprintf(" %-38s %-20s %d\n", mb_strimwidth($row['subject'], 0, 37, '?'), mb_strimwidth($row['areas'], 0, 19, '?'), $row['post_count']); } echo "\n"; } if (!empty($crossNetwork)) { echo "CROSS-NETWORK THREADS\n{$hr}\n"; echo sprintf(" %-38s %-24s %s\n", 'Subject', 'Networks', 'Posts'); echo " " . str_repeat('-', 68) . "\n"; foreach ($crossNetwork as $row) { echo sprintf(" %-38s %-24s %d\n", mb_strimwidth($row['subject'], 0, 37, '?'), mb_strimwidth($row['networks_list'], 0, 23, '?'), $row['post_count']); } echo "\n"; } echo "{$hr2}\n\n"; }; // Always print the raw stats $printRawStats(); if (!$useAi) { exit(0); } // ?? Build AI prompt ??????????????????????????????????????????????????????????? $promptLines = []; $promptLines[] = "You are analyzing echomail traffic on a multi-network FidoNet-style BBS system."; $promptLines[] = "The data below covers the past {$days} day(s). Generate a concise, engaging plain-text"; $promptLines[] = "digest in the style of a weekly newsletter. Include:"; $promptLines[] = "- A headline summary (totals, networks)"; $promptLines[] = "- Volume leaders by area and network (note which areas are dominated by bots/feeders)"; $promptLines[] = "- Who the most engaged *human* participants are this week"; $promptLines[] = "- Hot topics and notable threads, especially any cross-network ones"; $promptLines[] = "- Any interesting patterns or observations"; $promptLines[] = "Keep it conversational and concise. Use plain text with simple section headers (no markdown)."; $promptLines[] = ""; $promptLines[] = "=== OVERVIEW ==="; $promptLines[] = "Period: past {$days} days"; $promptLines[] = "Total messages: " . $totals['total']; $promptLines[] = "Active areas: " . $totals['active_areas']; $promptLines[] = "Networks: " . $totals['networks']; $promptLines[] = "Unique sending nodes: " . $totals['unique_nodes']; $promptLines[] = ""; $promptLines[] = "=== BY NETWORK ==="; foreach ($networks as $row) { $promptLines[] = "{$row['network']}: {$row['msg_count']} messages across {$row['area_count']} areas"; } $promptLines[] = ""; $promptLines[] = "=== TOP AREAS ==="; foreach ($areaCounts as $row) { $promptLines[] = "{$row['tag']} ({$row['network']}): {$row['msg_count']} messages ? {$row['description']}"; } $promptLines[] = ""; $promptLines[] = "=== TOP SENDERS BY VOLUME ==="; foreach ($topSenders as $row) { $promptLines[] = "{$row['from_name']} <{$row['from_address']}> [{$row['network']}]: {$row['msg_count']} messages"; } $promptLines[] = ""; if (!empty($mostRepliedTo)) { $promptLines[] = "=== MOST REPLIED-TO (indicates human engagement) ==="; foreach ($mostRepliedTo as $row) { $promptLines[] = "{$row['from_name']} <{$row['from_address']}>: {$row['reply_count']} replies received"; } $promptLines[] = ""; } if (!empty($threads)) { $promptLines[] = "=== MOST ACTIVE THREADS ==="; foreach ($threads as $row) { $promptLines[] = "\"{$row['subject']}\" ? {$row['areas']} ({$row['post_count']} posts)"; } $promptLines[] = ""; } if (!empty($crossNetwork)) { $promptLines[] = "=== CROSS-NETWORK THREADS (same subject active in multiple networks) ==="; foreach ($crossNetwork as $row) { $promptLines[] = "\"{$row['subject']}\" ? networks: {$row['networks_list']} ({$row['post_count']} posts)"; } $promptLines[] = ""; } $promptLines[] = "=== DAILY TRAFFIC ==="; foreach ($daily as $row) { $promptLines[] = "{$row['day']}: {$row['msg_count']} messages"; } $userPrompt = implode("\n", $promptLines); // ?? Call AI provider ?????????????????????????????????????????????????????????? $systemPrompt = "You are a weekly digest writer for a FidoNet/BBS community. " . "You write concise, insightful summaries of echomail network activity. " . "You understand FTN addressing, echo areas, bots/feeders vs human posters, " . "and the culture of the BBS/FidoNet community. " . "Write in plain text with clear section headers. No markdown formatting."; try { $aiService = AiService::create(); $configured = $aiService->getConfiguredProviders(); if (empty($configured)) { fwrite(STDERR, "No AI provider configured. Set ANTHROPIC_API_KEY or OPENAI_API_KEY in .env.\n"); fwrite(STDERR, "Run with --no-ai to skip AI summary.\n"); exit(1); } fwrite(STDERR, "Generating AI summary (provider: " . implode(', ', $configured) . ")...\n"); $response = $aiService->generateText(new AiRequest( 'echomail_summary', $systemPrompt, $userPrompt, null, // provider ? use AI_ECHOMAIL_SUMMARY_PROVIDER or AI_DEFAULT_PROVIDER null, // model ? use AI_ECHOMAIL_SUMMARY_MODEL or AI_DEFAULT_MODEL 0.5, // temperature ? a little creative 2048, // max output tokens 90 // timeout )); $summary = $response->getContent(); $usage = $response->getUsage(); } catch (\Throwable $e) { fwrite(STDERR, "AI summary failed: " . $e->getMessage() . "\n"); exit(1); } // ?? Output ???????????????????????????????????????????????????????????????????? $header = str_repeat('?', 72) . "\n AI-GENERATED DIGEST\n" . str_repeat('?', 72) . "\n\n"; $footer = "\n" . str_repeat('?', 72) . "\n" . sprintf(" Tokens: %d in / %d out | Model: %s\n", $usage->getInputTokens(), $usage->getOutputTokens(), $response->getModel()) . str_repeat('?', 72) . "\n"; if ($output !== null) { file_put_contents($output, $header . $summary . $footer); fwrite(STDERR, "AI summary written to {$output}\n"); } else { echo $header . $summary . $footer; }