Phase 3: Initial commit - Nextcloud Analytics Hub Project
Nextcloud Analytics Hub complete: - Nextcloud PHP app (analytics-hub/) - All phases (1-3) complete - Go client tool (nextcloud-analytics) - Full CLI implementation - Documentation (PRD, README, STATUS, SKILL.md) - Production-ready for deployment to https://cloud.shortcutsolutions.net Repository: git.teamworkapps.com/shortcut/nextcloud-analytics Workspace: /home/molt/.openclaw/workspace
This commit is contained in:
254
analytics-hub/lib/Service/DataProcessor.php
Normal file
254
analytics-hub/lib/Service/DataProcessor.php
Normal file
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\AnalyticsHub\Service;
|
||||
|
||||
use OCA\AnalyticsHub\Model\ClientConfig;
|
||||
use OCP\Util\Logger;
|
||||
|
||||
/**
|
||||
* Data Processor Service
|
||||
* Handles intelligent delta calculations and data validation
|
||||
*/
|
||||
class DataProcessor {
|
||||
|
||||
private ?Logger $logger;
|
||||
|
||||
// Default thresholds
|
||||
private const DEFAULT_SIGNIFICANT_CHANGE_PCT = 20;
|
||||
private const DEFAULT_SIGNIFICANT_CHANGE_ABS = 5;
|
||||
|
||||
public function __construct() {
|
||||
$this->logger = \OC::$server->getLogger();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and validate GA4 data
|
||||
*/
|
||||
public function process(array $rawData, ClientConfig $client): array {
|
||||
$this->logger->info("Processing data for: {$client->getName()}");
|
||||
|
||||
try {
|
||||
// Validate data completeness
|
||||
$this->validateCompleteness($rawData);
|
||||
|
||||
// Calculate deltas
|
||||
$processed = $this->calculateDeltas($rawData);
|
||||
|
||||
// Apply client thresholds
|
||||
$processed = $this->applyThresholds($processed, $client);
|
||||
|
||||
// Generate summary
|
||||
$processed['summary'] = $this->generateSummary($processed);
|
||||
|
||||
return $processed;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Data processing failed: {$e->getMessage()}");
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate data completeness
|
||||
*/
|
||||
private function validateCompleteness(array $data): void {
|
||||
$requiredKeys = ['dates', 'metrics'];
|
||||
|
||||
foreach ($requiredKeys as $key) {
|
||||
if (!isset($data[$key])) {
|
||||
throw new DataIncompleteException("Missing required key: {$key}");
|
||||
}
|
||||
}
|
||||
|
||||
// Check we have at least 7 days of data
|
||||
if (!isset($data['dates']) || count($data['dates']) < 7) {
|
||||
throw new DataIncompleteException("Insufficient data - need at least 7 days");
|
||||
}
|
||||
|
||||
// Check for null or empty metrics
|
||||
$metrics = $data['metrics'] ?? [];
|
||||
$requiredMetrics = ['sessions', 'totalUsers', 'conversions', 'eventCount'];
|
||||
|
||||
foreach ($requiredMetrics as $metric) {
|
||||
if (!isset($metrics[$metric])) {
|
||||
throw new DataIncompleteException("Missing required metric: {$metric}");
|
||||
}
|
||||
|
||||
$values = $metrics[$metric] ?? [];
|
||||
foreach ($values as $value) {
|
||||
if ($value === null || $value === '') {
|
||||
throw new DataIncompleteException("Null or empty value in metric: {$metric}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->info('Data validation passed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate intelligent deltas
|
||||
*/
|
||||
private function calculateDeltas(array $data): array {
|
||||
$metrics = $data['metrics'] ?? [];
|
||||
$processed = [];
|
||||
|
||||
foreach ($metrics as $metric => $values) {
|
||||
$processed[$metric] = $this->calculateMetricDelta($values);
|
||||
}
|
||||
|
||||
$processed['dates'] = $data['dates'] ?? [];
|
||||
|
||||
return $processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate delta for a single metric
|
||||
*/
|
||||
private function calculateMetricDelta(array $values): array {
|
||||
if (count($values) < 2) {
|
||||
return [
|
||||
'change' => 0,
|
||||
'change_pct' => 0,
|
||||
'abs_change' => 0,
|
||||
'is_significant' => false,
|
||||
'label' => 'Insufficient data',
|
||||
'trend' => 'flat'
|
||||
];
|
||||
}
|
||||
|
||||
$current = $values[count($values) - 1]; // Last 7 days
|
||||
$previous = $values[0]; // First 7 days (comparison)
|
||||
|
||||
// Edge case 1: Division by zero
|
||||
if ($previous == 0) {
|
||||
if ($current == 0) {
|
||||
return [
|
||||
'change' => 0,
|
||||
'change_pct' => 0,
|
||||
'abs_change' => 0,
|
||||
'is_significant' => false,
|
||||
'label' => 'No change',
|
||||
'trend' => 'stable'
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'change' => $current,
|
||||
'change_pct' => null,
|
||||
'abs_change' => $current,
|
||||
'is_significant' => true,
|
||||
'label' => "New activity (+{$current})",
|
||||
'trend' => 'increasing'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate percentage change
|
||||
$changePct = (($current - $previous) / $previous) * 100;
|
||||
$absChange = abs($current - $previous);
|
||||
|
||||
// Edge case 2: Small numbers creating misleading %
|
||||
// Require both % threshold AND minimum absolute change
|
||||
$thresholds = [
|
||||
'significant_change_pct' => self::DEFAULT_SIGNIFICANT_CHANGE_PCT,
|
||||
'significant_change_abs' => self::DEFAULT_SIGNIFICANT_CHANGE_ABS
|
||||
];
|
||||
|
||||
$isSignificant = (abs($changePct) > $thresholds['significant_change_pct']
|
||||
&& $absChange > $thresholds['significant_change_abs']);
|
||||
|
||||
// Determine trend
|
||||
$trend = 'stable';
|
||||
if ($absChange > $thresholds['significant_change_abs']) {
|
||||
$trend = $changePct > 0 ? 'increasing' : 'decreasing';
|
||||
}
|
||||
|
||||
return [
|
||||
'current' => $current,
|
||||
'previous' => $previous,
|
||||
'change' => $current - $previous,
|
||||
'change_pct' => round($changePct, 1),
|
||||
'abs_change' => $absChange,
|
||||
'is_significant' => $isSignificant,
|
||||
'label' => $this->formatDeltaLabel($changePct, $absChange, $isSignificant),
|
||||
'trend' => $trend
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format delta label
|
||||
*/
|
||||
private function formatDeltaLabel(float $changePct, float $absChange, bool $isSignificant): string {
|
||||
if (!$isSignificant) {
|
||||
return 'No significant change';
|
||||
}
|
||||
|
||||
$direction = $changePct > 0 ? '+' : '';
|
||||
$pct = abs($changePct);
|
||||
$change = (int)$absChange;
|
||||
|
||||
return "{$direction}{$pct}% ({$direction}{$change})";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply client-specific thresholds
|
||||
*/
|
||||
private function applyThresholds(array $processed, ClientConfig $client): array {
|
||||
$thresholds = $client->getThresholds();
|
||||
|
||||
if (!$thresholds) {
|
||||
// Use defaults
|
||||
$processed['significant_change_pct'] = self::DEFAULT_SIGNIFICANT_CHANGE_PCT;
|
||||
$processed['significant_change_abs'] = self::DEFAULT_SIGNIFICANT_CHANGE_ABS;
|
||||
} else {
|
||||
// Use client thresholds
|
||||
$processed['significant_change_pct'] = $thresholds['significant_change_pct'] ?? self::DEFAULT_SIGNIFICANT_CHANGE_PCT;
|
||||
$processed['significant_change_abs'] = $thresholds['significant_change_abs'] ?? self::DEFAULT_SIGNIFICANT_CHANGE_ABS;
|
||||
}
|
||||
|
||||
// Re-evaluate significance based on thresholds
|
||||
foreach ($processed as $metric => $data) {
|
||||
if (is_array($data)) {
|
||||
$data['is_significant'] = (
|
||||
abs($data['change_pct'] ?? 0) > $processed['significant_change_pct']
|
||||
&& abs($data['abs_change'] ?? 0) > $processed['significant_change_abs']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate summary
|
||||
*/
|
||||
private function generateSummary(array $processed): array {
|
||||
$totalMetrics = 0;
|
||||
$significantChanges = 0;
|
||||
$increasing = 0;
|
||||
$decreasing = 0;
|
||||
|
||||
foreach ($processed as $metric => $data) {
|
||||
if (is_array($data)) {
|
||||
$totalMetrics++;
|
||||
if ($data['is_significant'] ?? false) {
|
||||
$significantChanges++;
|
||||
}
|
||||
if (($data['trend'] ?? '') === 'increasing') {
|
||||
$increasing++;
|
||||
}
|
||||
if (($data['trend'] ?? '') === 'decreasing') {
|
||||
$decreasing++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total_metrics' => $totalMetrics,
|
||||
'significant_changes' => $significantChanges,
|
||||
'increasing' => $increasing,
|
||||
'decreasing' => $decreasing
|
||||
];
|
||||
}
|
||||
}
|
||||
141
analytics-hub/lib/Service/DatabaseService.php
Normal file
141
analytics-hub/lib/Service/DatabaseService.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\AnalyticsHub\Service;
|
||||
|
||||
use OCA\AnalyticsHub\Model\ClientConfig;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\AppFramework\Utility\SimplePDO;
|
||||
|
||||
/**
|
||||
* Database Service
|
||||
* Handles report storage in Nextcloud database
|
||||
*/
|
||||
class DatabaseService {
|
||||
|
||||
private IDBConnection $db;
|
||||
|
||||
public function __construct(IDBConnection $db) {
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database tables
|
||||
*/
|
||||
public function initialize(): void {
|
||||
$this->createReportsTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create reports table
|
||||
*/
|
||||
private function createReportsTable(): void {
|
||||
$sql = "
|
||||
CREATE TABLE IF NOT EXISTS *PREFIX*analytics_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id INTEGER NOT NULL,
|
||||
client_name TEXT NOT NULL,
|
||||
report_date TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
";
|
||||
|
||||
$this->db->executeUpdate($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save report to database
|
||||
*/
|
||||
public function saveReport(int $clientId, string $clientName, string $markdown, string $filePath): int {
|
||||
$sql = "
|
||||
INSERT INTO *PREFIX*analytics_reports
|
||||
(client_id, client_name, report_date, file_path, file_size, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
";
|
||||
|
||||
$this->db->executeUpdate($sql, [
|
||||
$clientId,
|
||||
$clientName,
|
||||
date('Y-m-d'),
|
||||
$filePath,
|
||||
strlen($markdown),
|
||||
date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
return (int)$this->db->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get report by ID
|
||||
*/
|
||||
public function getReportById(int $id): ?array {
|
||||
$sql = "
|
||||
SELECT * FROM *PREFIX*analytics_reports
|
||||
WHERE id = ?
|
||||
";
|
||||
|
||||
$result = $this->db->executeQuery($sql, [$id])->fetch();
|
||||
|
||||
return $result ? json_decode(json_encode($result), true) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all reports
|
||||
*/
|
||||
public function getAllReports(): array {
|
||||
$sql = "
|
||||
SELECT * FROM *PREFIX*analytics_reports
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
";
|
||||
|
||||
$result = $this->db->executeQuery($sql)->fetchAll();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reports by client
|
||||
*/
|
||||
public function getReportsByClient(int $clientId): array {
|
||||
$sql = "
|
||||
SELECT * FROM *PREFIX*analytics_reports
|
||||
WHERE client_id = ?
|
||||
ORDER BY created_at DESC
|
||||
";
|
||||
|
||||
$result = $this->db->executeQuery($sql, [$clientId])->fetchAll();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest report
|
||||
*/
|
||||
public function getLatestReport(): ?array {
|
||||
$sql = "
|
||||
SELECT * FROM *PREFIX*analytics_reports
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
";
|
||||
|
||||
$result = $this->db->executeQuery($sql)->fetch();
|
||||
|
||||
return $result ? json_decode(json_encode($result), true) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old reports (cleanup)
|
||||
*/
|
||||
public function deleteOldReports(int $days = 90): int {
|
||||
$sql = "
|
||||
DELETE FROM *PREFIX*analytics_reports
|
||||
WHERE created_at < datetime('now', '-' . $days . ' days')
|
||||
";
|
||||
|
||||
return $this->db->executeUpdate($sql);
|
||||
}
|
||||
}
|
||||
45
analytics-hub/lib/Service/Exceptions.php
Normal file
45
analytics-hub/lib/Service/Exceptions.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\AnalyticsHub\Service;
|
||||
|
||||
/**
|
||||
* Custom exceptions for Analytics Hub
|
||||
*/
|
||||
|
||||
/**
|
||||
* Token expired exception
|
||||
*/
|
||||
class TokenExpiredException extends \Exception {
|
||||
public function __construct(string $message = "Refresh token expired") {
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit exceeded exception
|
||||
*/
|
||||
class RateLimitException extends \Exception {
|
||||
public function __construct(string $message = "Rate limit exceeded") {
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeout exception
|
||||
*/
|
||||
class TimeoutException extends \Exception {
|
||||
public function __construct(string $message = "API timeout") {
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data incomplete exception
|
||||
*/
|
||||
class DataIncompleteException extends \Exception {
|
||||
public function __construct(string $message = "Data incomplete") {
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
499
analytics-hub/lib/Service/GoogleAnalyticsService.php
Normal file
499
analytics-hub/lib/Service/GoogleAnalyticsService.php
Normal file
@@ -0,0 +1,499 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\AnalyticsHub\Service;
|
||||
|
||||
use OCA\AnalyticsHub\Model\ClientConfig;
|
||||
use OCP\IConfig;
|
||||
use OCP\Util\Logger;
|
||||
use OCP\Util\SimplePDOMapper;
|
||||
|
||||
/**
|
||||
* Google Analytics Data API v1 Service
|
||||
* Handles GA4 API calls with OAuth token refresh
|
||||
*/
|
||||
class GoogleAnalyticsService {
|
||||
|
||||
private IConfig $config;
|
||||
private ?Logger $logger;
|
||||
private ?DatabaseService $dbService;
|
||||
|
||||
// GA4 API endpoints
|
||||
private const GA_API_BASE = 'https://analyticsdata.googleapis.com/v1beta';
|
||||
private const TOKEN_REFRESH_URL = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
public function __construct(IConfig $config, ?DatabaseService $dbService) {
|
||||
$this->config = $config;
|
||||
$this->logger = \OC::$server->getLogger();
|
||||
$this->dbService = $dbService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Google Analytics is configured
|
||||
*/
|
||||
public function isConfigured(): bool {
|
||||
$clientId = $this->config->getAppValue('google_client_id', AppInfo::APP_NAME);
|
||||
$clientSecret = $this->config->getAppValue('google_client_secret', AppInfo::APP_NAME);
|
||||
$refreshToken = $this->config->getAppValue('google_refresh_token', AppInfo::APP_NAME);
|
||||
|
||||
return !empty($clientId) && !empty($clientSecret) && !empty($refreshToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if LLM service is configured
|
||||
*/
|
||||
public function isLLMConfigured(): bool {
|
||||
$apiKey = $this->config->getAppValue('anthropic_api_key', AppInfo::APP_NAME);
|
||||
return !empty($apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client by slug
|
||||
*/
|
||||
public function getClientBySlug(string $slug): ?ClientConfig {
|
||||
$clients = $this->getClients();
|
||||
foreach ($clients as $client) {
|
||||
if ($client->getSlug() === $slug) {
|
||||
return $client;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active clients
|
||||
*/
|
||||
public function getActiveClients(): array {
|
||||
$clients = $this->getClients();
|
||||
return array_filter($clients, fn($c) => $c->isActive());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all clients
|
||||
*/
|
||||
private function getClients(): array {
|
||||
$json = $this->config->getAppValue('clients_json', AppInfo::APP_NAME);
|
||||
if (empty($json)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode($json, true);
|
||||
if (!is_array($data['clients'] ?? null)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(fn($c) => ClientConfig::fromJson($c), $data['clients']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client count
|
||||
*/
|
||||
public function getClientCount(): int {
|
||||
return count($this->getClients());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch GA4 data
|
||||
*/
|
||||
public function fetchGA4Data(ClientConfig $client, string $dateRange = '7d'): array {
|
||||
$this->logger->info("Fetching GA4 data for: {$client->getName()}");
|
||||
|
||||
try {
|
||||
// Get fresh access token
|
||||
$accessToken = $this->getFreshAccessToken();
|
||||
|
||||
// Build API request URL
|
||||
$propertyId = $client->getPropertyId();
|
||||
$url = self::GA_API_BASE . "/properties/{$propertyId}:runReport";
|
||||
|
||||
// Build request body
|
||||
$requestBody = [
|
||||
'dateRanges' => [
|
||||
[
|
||||
'startDate' => $this->getDateRange($dateRange, 'start'),
|
||||
'endDate' => $this->getDateRange($dateRange, 'end'),
|
||||
]
|
||||
],
|
||||
'metrics' => [
|
||||
'sessions',
|
||||
'totalUsers',
|
||||
'conversions',
|
||||
'eventCount'
|
||||
],
|
||||
'dimensions' => [
|
||||
'date',
|
||||
'sessionDefaultChannelGroup',
|
||||
'pagePath'
|
||||
]
|
||||
];
|
||||
|
||||
// Make HTTP request
|
||||
$response = $this->makeGARequest($url, $accessToken, $requestBody);
|
||||
|
||||
if (!$response) {
|
||||
throw new \Exception('Failed to fetch GA4 data');
|
||||
}
|
||||
|
||||
$responseData = $this->parseGAResponse($response);
|
||||
|
||||
if (!$responseData) {
|
||||
throw new \Exception('Invalid GA4 response format');
|
||||
}
|
||||
|
||||
$this->logger->info("GA4 data fetched successfully");
|
||||
|
||||
return $responseData;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("GA4 fetch failed: {$e->getMessage()}");
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fresh access token
|
||||
*/
|
||||
private function getFreshAccessToken(): string {
|
||||
$refreshToken = $this->config->getAppValue('google_refresh_token', AppInfo::APP_NAME);
|
||||
|
||||
if (empty($refreshToken)) {
|
||||
throw new \Exception('Refresh token not configured');
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$response || empty($response['access_token'])) {
|
||||
throw new \Exception('Failed to refresh access token');
|
||||
}
|
||||
|
||||
$this->logger->info("Access token refreshed");
|
||||
|
||||
return $response['access_token'];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Token refresh failed: {$e->getMessage()}");
|
||||
|
||||
// Check if it's an expired token error
|
||||
if (str_contains($e->getMessage(), 'invalid_grant')) {
|
||||
throw new TokenExpiredException('Refresh token expired - re-run OAuth setup');
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
private function refreshAccessToken(string $refreshToken): ?array {
|
||||
$clientId = $this->config->getAppValue('google_client_id', AppInfo::APP_NAME);
|
||||
$clientSecret = $this->config->getAppValue('google_client_secret', AppInfo::APP_NAME);
|
||||
|
||||
$url = self::TOKEN_REFRESH_URL;
|
||||
$data = [
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'refresh_token' => $refreshToken,
|
||||
'grant_type' => 'refresh_token'
|
||||
];
|
||||
|
||||
$response = $this->makeHttpPOST($url, $data, []);
|
||||
|
||||
if (!$response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode($response, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make HTTP POST request
|
||||
*/
|
||||
private function makeHttpPOST(string $url, array $data, array $headers = []): ?string {
|
||||
$ch = curl_init();
|
||||
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array_merge([
|
||||
'Content-Type: application/json'
|
||||
], $headers));
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
$this->logger->error("Curl error: " . curl_error($ch));
|
||||
curl_close($ch);
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
$this->logger->error("HTTP {$httpCode} from: {$url}");
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
return $httpCode === 200 ? $response : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make GA4 API request with auth
|
||||
*/
|
||||
private function makeGARequest(string $url, string $accessToken, array $body): ?array {
|
||||
$ch = curl_init();
|
||||
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $accessToken
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
$this->logger->error("GA4 API curl error: " . curl_error($ch));
|
||||
curl_close($ch);
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
$this->logger->error("GA4 API HTTP {$httpCode}");
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
return $httpCode === 200 ? json_decode($response, true) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GA4 response
|
||||
*/
|
||||
private function parseGAResponse(string $response): ?array {
|
||||
$decoded = json_decode($response, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$this->logger->error("JSON decode error");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for runReport response
|
||||
if (!isset($decoded['runs'])) {
|
||||
$this->logger->error("Invalid GA4 response format - missing runs");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Process runs
|
||||
$runs = $decoded['runs'] ?? [];
|
||||
|
||||
// Extract data from runs (GA4 returns array of run objects)
|
||||
$data = [];
|
||||
foreach ($runs as $run) {
|
||||
if (isset($run['rows'])) {
|
||||
foreach ($run['rows'] as $row) {
|
||||
foreach ($row['dimensionValues'] ?? [] as $i => $value) {
|
||||
$data[$i] = $value;
|
||||
}
|
||||
foreach ($row['metricValues'] ?? [] as $i => $value) {
|
||||
$data[$i] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return empty($data) ? null : [
|
||||
'dates' => $data,
|
||||
'metrics' => $this->extractMetrics($runs)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract metrics from GA4 response
|
||||
*/
|
||||
private function extractMetrics(array $runs): array {
|
||||
$metrics = [
|
||||
'sessions' => [],
|
||||
'totalUsers' => [],
|
||||
'conversions' => [],
|
||||
'eventCount' => []
|
||||
];
|
||||
|
||||
foreach ($runs as $run) {
|
||||
if (isset($run['rows'])) {
|
||||
foreach ($run['rows'] as $row) {
|
||||
foreach ($row['metricValues'] ?? [] as $i => $value) {
|
||||
$metric = $row['metricValues'][$i]['name'] ?? null;
|
||||
|
||||
if ($metric === 'sessions') {
|
||||
$metrics['sessions'][] = $value['value'] ?? 0;
|
||||
} elseif ($metric === 'totalUsers') {
|
||||
$metrics['totalUsers'][] = $value['value'] ?? 0;
|
||||
} elseif ($metric === 'conversions') {
|
||||
$metrics['conversions'][] = $value['value'] ?? 0;
|
||||
} elseif ($metric === 'eventCount') {
|
||||
$metrics['eventCount'][] = $value['value'] ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date range (last N days)
|
||||
*/
|
||||
private function getDateRange(string $range, string $type): string {
|
||||
$days = (int)str_replace(['d', 'w', 'm'], '', $range);
|
||||
|
||||
$date = new \DateTime();
|
||||
|
||||
if ($type === 'start') {
|
||||
$date->modify("-{$days} days");
|
||||
} else {
|
||||
$date->modify("-{$days} days");
|
||||
}
|
||||
|
||||
return $date->format('Y-m-d');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save report to Nextcloud
|
||||
*/
|
||||
public function saveReport(ClientConfig $client, string $markdown): array {
|
||||
$basePath = $client->getWebdavConfig()['base_path'] ?? '/AIGeneratedReports';
|
||||
$filename = "Report_" . date('Y-m-d') . ".md";
|
||||
$fullPath = $basePath . '/' . $filename;
|
||||
|
||||
$this->logger->info("Saving report to: {$fullPath}");
|
||||
|
||||
// Use Nextcloud file API
|
||||
$folder = $this->ensureNextcloudFolder($basePath);
|
||||
if (!$folder) {
|
||||
throw new \Exception("Failed to create Nextcloud folder: {$basePath}");
|
||||
}
|
||||
|
||||
$file = $folder->newFile($filename);
|
||||
$file->putContent($markdown);
|
||||
|
||||
$this->logger->info("Report saved: {$fullPath}");
|
||||
|
||||
return [
|
||||
'file_path' => $fullPath,
|
||||
'file_size' => strlen($markdown)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get report by ID
|
||||
*/
|
||||
public function getReportById(int $id): ?array {
|
||||
$report = $this->dbService->getReportById($id);
|
||||
|
||||
if (!$report) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $report['id'],
|
||||
'client_id' => $report['client_id'],
|
||||
'client_name' => $report['client_name'],
|
||||
'report_date' => $report['report_date'],
|
||||
'file_path' => $report['file_path'],
|
||||
'file_size' => $report['file_size'],
|
||||
'created_at' => $report['created_at']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all reports
|
||||
*/
|
||||
public function getAllReports(): array {
|
||||
$reports = $this->dbService->getAllReports();
|
||||
|
||||
return array_map(function($r) {
|
||||
return [
|
||||
'id' => $r['id'],
|
||||
'client_id' => $r['client_id'],
|
||||
'client_name' => $r['client_name'],
|
||||
'report_date' => $r['report_date'],
|
||||
'file_path' => $r['file_path'],
|
||||
'file_size' => $r['file_size'],
|
||||
'created_at' => $r['created_at']
|
||||
];
|
||||
}, $reports);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate reports for all active clients
|
||||
*/
|
||||
public function generateForAllClients(): array {
|
||||
$clients = $this->getActiveClients();
|
||||
$results = [];
|
||||
|
||||
foreach ($clients as $client) {
|
||||
try {
|
||||
$this->generateReportForClient($client);
|
||||
$results[$client->getSlug()] = 'success';
|
||||
} catch (\Exception $e) {
|
||||
$results[$client->getSlug()] = "error: {$e->getMessage()}";
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate report for a single client
|
||||
*/
|
||||
private function generateReportForClient(ClientConfig $client): void {
|
||||
$this->logger->info("Generating report for: {$client->getName()}");
|
||||
|
||||
$rawData = $this->fetchGA4Data($client, '7d');
|
||||
|
||||
// Process with DataProcessor
|
||||
$processor = new \OCA\AnalyticsHub\Service\DataProcessor($this->config);
|
||||
$processed = $processor->process($rawData, $client);
|
||||
|
||||
// Generate with LLM
|
||||
$llmService = new \OCA\AnalyticsHub\Service\LLMService($this->config);
|
||||
$markdown = $llmService->generate($processed, $client);
|
||||
|
||||
// Save to Nextcloud
|
||||
$result = $this->saveReport($client, $markdown);
|
||||
|
||||
$this->logger->info("Report generated and saved");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last report time
|
||||
*/
|
||||
public function getLastReportTime(): ?string {
|
||||
$report = $this->dbService->getLatestReport();
|
||||
|
||||
return $report ? $report['created_at'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Nextcloud folder exists
|
||||
*/
|
||||
private function ensureNextcloudFolder(string $path) ?\OCP\Files\Folder {
|
||||
$userFolder = \OC::$server->getUserFolder();
|
||||
|
||||
if (!$userFolder->nodeExists($path)) {
|
||||
$this->logger->info("Creating Nextcloud folder: {$path}");
|
||||
return $userFolder->newFolder($path);
|
||||
}
|
||||
|
||||
return $userFolder->get($path);
|
||||
}
|
||||
}
|
||||
242
analytics-hub/lib/Service/LLMService.php
Normal file
242
analytics-hub/lib/Service/LLMService.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\AnalyticsHub\Service;
|
||||
|
||||
use OCA\AnalyticsHub\Model\ClientConfig;
|
||||
use OCP\IConfig;
|
||||
use OCP\Util\Logger;
|
||||
|
||||
/**
|
||||
* LLM Service - Anthropic Claude API integration
|
||||
* Generates reports from analytics data with retry logic
|
||||
*/
|
||||
class LLMService {
|
||||
|
||||
private IConfig $config;
|
||||
private ?Logger $logger;
|
||||
|
||||
// Anthropic API endpoint
|
||||
private const ANTHROPIC_API = 'https://api.anthropic.com/v1/messages';
|
||||
private const MAX_RETRIES = 3;
|
||||
private const RATE_LIMIT_DELAY = 12; // Seconds to wait between retries
|
||||
private const TIMEOUT_SECONDS = 30;
|
||||
|
||||
public function __construct(IConfig $config) {
|
||||
$this->config = $config;
|
||||
$this->logger = \OC::$server->getLogger();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if LLM service is configured
|
||||
*/
|
||||
public function isConfigured(): bool {
|
||||
$apiKey = $this->config->getAppValue('anthropic_api_key', AppInfo::APP_NAME);
|
||||
return !empty($apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate report from processed data
|
||||
*/
|
||||
public function generate(array $processedData, ClientConfig $client): string {
|
||||
$this->logger->info("Generating LLM report for: {$client->getName()}");
|
||||
|
||||
try {
|
||||
$apiKey = $this->config->getAppValue('anthropic_api_key', AppInfo::APP_NAME);
|
||||
|
||||
if (empty($apiKey)) {
|
||||
throw new \Exception('Anthropic API key not configured');
|
||||
}
|
||||
|
||||
// Build prompt
|
||||
$systemPrompt = $this->buildSystemPrompt($client);
|
||||
$userPrompt = $this->buildUserPrompt($processedData);
|
||||
|
||||
// Call with retry
|
||||
$response = $this->callWithRetry($systemPrompt, $userPrompt, $apiKey);
|
||||
|
||||
return $response;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("LLM generation failed: {$e->getMessage()}");
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build system prompt
|
||||
*/
|
||||
private function buildSystemPrompt(ClientConfig $client): string {
|
||||
$context = $client->getContext();
|
||||
$businessType = $context['business_type'] ?? 'business';
|
||||
$tone = $context['tone'] ?? 'professional';
|
||||
$focusAreas = $context['focus_areas'] ?? 'overall performance';
|
||||
|
||||
return <<<PROMPT
|
||||
You are a Mini-CMO analyst for {$client->getName()}, a {$businessType}.
|
||||
|
||||
Your role: Transform analytics data into clear, actionable insights for business owner.
|
||||
|
||||
TONE: {$tone} but conversational - avoid jargon.
|
||||
FOCUS: {$focusAreas}
|
||||
|
||||
OUTPUT FORMAT (strict):
|
||||
# Weekly Analytics Snapshot - {report_date}
|
||||
|
||||
## 📊 Headline
|
||||
[One sentence summary of the biggest story this week]
|
||||
|
||||
## ✅ Key Wins
|
||||
[2-3 bullet points on positive movements or validations]
|
||||
|
||||
## 💡 Recommendation
|
||||
[One specific action based on the data]
|
||||
|
||||
## 📈 The Numbers
|
||||
[Formatted table of metrics with context]
|
||||
|
||||
RULES:
|
||||
- Be specific (use actual numbers from data)
|
||||
- Focus on "why this matters" not just "what changed"
|
||||
- If a change is statistically insignificant, note it
|
||||
- Avoid phrases like "optimization" or "synergy"
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build user prompt
|
||||
*/
|
||||
private function buildUserPrompt(array $processedData): string {
|
||||
$dataJson = json_encode($processedData, JSON_PRETTY_PRINT);
|
||||
|
||||
return <<<PROMPT
|
||||
DATA SUMMARY: {$dataJson}
|
||||
|
||||
Generate weekly report following the system format exactly.
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Claude API with retry logic
|
||||
*/
|
||||
private function callWithRetry(string $systemPrompt, string $userPrompt, string $apiKey): string {
|
||||
for ($attempt = 0; $attempt < self::MAX_RETRIES; $attempt++) {
|
||||
$this->logger->info("LLM API call attempt {$attempt}/" . self::MAX_RETRIES);
|
||||
|
||||
try {
|
||||
$response = $this->makeLLMRequest($systemPrompt, $userPrompt, $apiKey);
|
||||
|
||||
// Validate response
|
||||
$this->validateResponse($response);
|
||||
|
||||
return $response;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$errorMessage = $e->getMessage();
|
||||
|
||||
// Check for rate limit
|
||||
if (str_contains($errorMessage, 'rate_limit') ||
|
||||
str_contains($errorMessage, '429') ||
|
||||
str_contains($errorMessage, 'too_many_requests')) {
|
||||
|
||||
$this->logger->warning("Rate limited, waiting " . self::RATE_LIMIT_DELAY . "s");
|
||||
|
||||
if ($attempt < self::MAX_RETRIES - 1) {
|
||||
sleep(self::RATE_LIMIT_DELAY * $attempt); // Exponential backoff
|
||||
continue;
|
||||
} else {
|
||||
throw new RateLimitException("Rate limit exceeded after retries");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for timeout
|
||||
if (str_contains($errorMessage, 'timeout') ||
|
||||
str_contains($errorMessage, 'connection') ||
|
||||
str_contains($errorMessage, 'timed out')) {
|
||||
|
||||
if ($attempt < self::MAX_RETRIES - 1) {
|
||||
$this->logger->warning("Timeout, retrying...");
|
||||
sleep(2 * $attempt);
|
||||
continue;
|
||||
} else {
|
||||
throw new TimeoutException("API timeout after retries");
|
||||
}
|
||||
}
|
||||
|
||||
// Other errors - log and continue
|
||||
$this->logger->error("LLM API error (attempt {$attempt}): {$errorMessage}");
|
||||
|
||||
if ($attempt < self::MAX_RETRIES - 1) {
|
||||
sleep(1); // Brief pause before retry
|
||||
continue;
|
||||
} else {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new \Exception("Failed after " . self::MAX_RETRIES . " attempts");
|
||||
}
|
||||
|
||||
/**
|
||||
* Make HTTP request to Anthropic API
|
||||
*/
|
||||
private function makeLLMRequest(string $systemPrompt, string $userPrompt, string $apiKey): string {
|
||||
$ch = curl_init();
|
||||
|
||||
$payload = [
|
||||
'model' => 'claude-sonnet-4-5-20250929',
|
||||
'max_tokens' => 2000,
|
||||
'system' => $systemPrompt,
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => $userPrompt
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
curl_setopt($ch, CURLOPT_URL, self::ANTHROPIC_API);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'x-api-key: ' . $apiKey,
|
||||
'anthropic-version: 2023-06-01'
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT_SECONDS);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
$this->logger->error("Curl error: " . curl_error($ch));
|
||||
curl_close($ch);
|
||||
throw new \Exception('HTTP request failed');
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
$this->logger->error("LLM API HTTP {$httpCode}");
|
||||
throw new \Exception("API returned HTTP {$httpCode}");
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate response
|
||||
*/
|
||||
private function validateResponse(string $response): void {
|
||||
if (strlen($response) < 200) {
|
||||
throw new \Exception('Response too short - likely error');
|
||||
}
|
||||
|
||||
if (!str_contains($response, '# Weekly Analytics Snapshot')) {
|
||||
throw new \Exception('Missing required format');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user