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:
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