seedproject-web/api/app/LLM/GeminiService.php
Carlos Arias 1559ce017d chore: scaffold SeedProject base (Phase 1)
Clean-room copy of the reusable engines from comiida, with all
instance data, secrets, dependencies, and build output excluded:
- app/         Astro theme skeleton (no comiida blog posts; hero image -> placeholder)
- api/         SeedProject PHP framework (no vendor/.env/config.php)
- content-pipeline/  engine only (scripts/admin/prompts; empty runtime state)
- astroagent.config.json + app/.astroagent/skills

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYHWLHihq3v9nxNwoPCKSn
2026-07-04 22:53:10 +00:00

749 lines
No EOL
25 KiB
PHP

<?php
namespace App\LLM;
use Exception;
use Gemini as GeminiClient;
use Gemini\Data\Content;
use Gemini\Data\GenerationConfig;
use Gemini\Data\Tool;
use Gemini\Data\GoogleSearch;
use Gemini\Enums\ResponseMimeType;
class GeminiService {
private $client;
private string $defaultModel = 'gemini-3-pro-preview';
private int $defaultMaxTokens = 8192;
private ?string $apiKey;
private string $templatePath;
// Model presets for different use cases
// - pro: Best for humanizing text, creative nuance, SEO strategy, and fact-checking
// - flash: Best for bulk processing, initial drafts, AEO structure checks
// - flash-lite: Cost saver for simple tasks like meta-tag generation or keyword extraction
public const MODELS = [
'pro' => 'gemini-3-pro-preview', // Best for humanizing, SEO strategy
'flash' => 'gemini-3-flash-preview', // Faster, good for SEO review pass
'flash-lite' => 'gemini-2.5-flash-lite', // Cost saver for simple tasks
'flash-2.5' => 'gemini-2.5-flash', // Legacy flash model
];
// Human-readable labels for UI dropdowns
public const MODEL_LABELS = [
'gemini-3-pro-preview' => 'Gemini 3 Pro (Best for Humanizing)',
'gemini-3-flash-preview' => 'Gemini 3 Flash (Fast & Smart)',
'gemini-2.5-flash' => 'Gemini 2.5 Flash (Legacy)',
'gemini-2.5-flash-lite' => 'Gemini 2.5 Flash Lite (Budget)',
];
/**
* Constructor
*/
public function __construct(?string $apiKey = null, ?string $templatePath = null, int $timeout = 300)
{
$this->apiKey = $apiKey ?? (defined('GEMINI_API_KEY') ? GEMINI_API_KEY : null);
if (empty($this->apiKey)) {
throw new Exception('Gemini API key is required.');
}
$this->templatePath = $templatePath ?? '/www/wwwroot/appCarlos/templates/prompts';
if (!is_dir($this->templatePath)) {
throw new Exception("Template directory not found: {$this->templatePath}");
}
if (!is_readable($this->templatePath)) {
throw new Exception("Template directory is not readable: {$this->templatePath}");
}
set_time_limit($timeout);
ini_set('default_socket_timeout', (string) $timeout);
$this->client = GeminiClient::client($this->apiKey);
}
// =========================================================================
// CONNECTION & HEALTH
// =========================================================================
public function testConnection(): array
{
$startTime = microtime(true);
try {
$response = $this->client
->generativeModel(model: $this->defaultModel)
->generateContent('Reply with only: OK');
$latency = round((microtime(true) - $startTime) * 1000);
// Extract usage from response
$usage = $this->extractUsage($response);
return [
'success' => true,
'message' => 'Connected to Gemini API',
'model' => $this->defaultModel,
'response' => trim($response->text()),
'latency_ms' => $latency,
'usage' => $usage,
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Connection failed',
'error' => $e->getMessage(),
'error_type' => get_class($e),
];
}
}
public function isConnected(): bool
{
return $this->testConnection()['success'];
}
public function getStatus(): array
{
$connection = $this->testConnection();
return [
'connected' => $connection['success'],
'model' => $this->defaultModel,
'max_tokens' => $this->defaultMaxTokens,
'api_key_preview' => substr($this->apiKey, 0, 10) . '...' . substr($this->apiKey, -4),
'template_path' => $this->templatePath,
'connection_details' => $connection,
];
}
// =========================================================================
// TEMPLATE LOADING
// =========================================================================
public function loadTemplate(string $filename): string
{
$path = $this->templatePath . '/' . $filename;
if (!file_exists($path)) {
throw new Exception("Template not found: {$path}");
}
$content = file_get_contents($path);
if ($content === false) {
throw new Exception("Failed to read template: {$path}");
}
return $content;
}
public function setTemplatePath(string $path): self
{
$this->templatePath = rtrim($path, '/');
return $this;
}
public function getTemplatePath(): string
{
return $this->templatePath;
}
// =========================================================================
// BASIC MESSAGING
// =========================================================================
public function message(string $prompt, ?string $systemPrompt = null, array $options = []): string
{
$response = $this->messageWithMeta($prompt, $systemPrompt, $options);
return $response['content'];
}
/**
* GEMINI SERVICE UPDATE
*
* Replace the existing messageWithMeta() method with this updated version
* that supports JSON response mode via 'response_mime_type' option.
*
* Location: App\Components\GeminiService
* Method: messageWithMeta()
*/
public function messageWithMeta(string $prompt, ?string $systemPrompt = null, array $options = []): array
{
$startTime = microtime(true);
$model = $this->client->generativeModel(
model: $options['model'] ?? $this->defaultModel
);
// Add system instruction if provided
if ($systemPrompt) {
$model = $model->withSystemInstruction(Content::parse($systemPrompt));
}
// Build generation config
$configParams = [
'maxOutputTokens' => $options['max_tokens'] ?? $this->defaultMaxTokens,
];
if (isset($options['response_mime_type']) && $options['response_mime_type'] === 'application/json') {
$configParams['responseMimeType'] = ResponseMimeType::APPLICATION_JSON;
}
if (isset($options['temperature'])) {
$configParams['temperature'] = $options['temperature'];
}
if (isset($options['stop_sequences'])) {
$configParams['stopSequences'] = $options['stop_sequences'];
}
// Support JSON response mode
if (isset($options['response_mime_type']) && $options['response_mime_type'] === 'application/json') {
$configParams['responseMimeType'] = ResponseMimeType::APPLICATION_JSON;
}
$model = $model->withGenerationConfig(new GenerationConfig(...$configParams));
// Enable Google Search if requested
if ($options['use_search'] ?? false) {
$model = $model->withTool(new Tool(googleSearch: new GoogleSearch()));
}
$response = $model->generateContent($prompt);
$usage = $this->extractUsage($response);
return [
'content' => $response->text(),
'model' => $options['model'] ?? $this->defaultModel,
'stop_reason' => $response->candidates[0]->finishReason ?? null,
'latency_ms' => round((microtime(true) - $startTime) * 1000),
'usage' => $usage,
];
}
// =========================================================================
// ARTICLE GENERATION
// =========================================================================
/**
* Build the dynamic variables block (topic, word count, etc.)
*/
public function buildArticleVariables(array $config): string
{
$lines = [];
if (!empty($config['topic'])) {
$lines[] = "TOPIC: {$config['topic']}";
}
if (!empty($config['domain'])) {
$lines[] = "DOMAIN: {$config['domain']}";
}
if (!empty($config['industry'])) {
$lines[] = "INDUSTRY: {$config['industry']}";
}
if (!empty($config['target_audience'])) {
$lines[] = "AUDIENCE: {$config['target_audience']}";
}
if (!empty($config['knowledge_level'])) {
$lines[] = "KNOWLEDGE_LEVEL: {$config['knowledge_level']}";
}
if (!empty($config['tone'])) {
$lines[] = "TONE: {$config['tone']}";
}
if (!empty($config['word_count'])) {
$lines[] = "WORD_COUNT: {$config['word_count']}";
}
if (!empty($config['location'])) {
$lines[] = "LOCATION: {$config['location']}";
}
return implode("\n", $lines);
}
/**
* Generate article using template + variables
* Uses Google Search for fact verification
*/
public function generateArticle(string $templateFile, array $config): array
{
$startTime = microtime(true);
// Load template - this includes the master prompt + business profile
$template = $this->loadTemplate($templateFile);
// Build dynamic variables
$variables = $this->buildArticleVariables($config);
// Combine template with variables
$systemPrompt = $template . "\n\n" . $variables;
// Build the model with configuration
$model = $this->client->generativeModel(
model: $config['model'] ?? $this->defaultModel
);
// Add system instruction (template + variables)
$model = $model->withSystemInstruction(Content::parse($systemPrompt));
// Add generation config
$model = $model->withGenerationConfig(new GenerationConfig(
maxOutputTokens: $config['max_tokens'] ?? $this->defaultMaxTokens,
));
// Enable Google Search for fact verification
if ($config['use_search'] ?? true) {
$model = $model->withTool(new Tool(googleSearch: new GoogleSearch()));
}
// Generate content
$userPrompt = $config['prompt'] ?? 'BEGIN GENERATION NOW.';
$response = $model->generateContent($userPrompt);
$usage = $this->extractUsage($response);
$latency = round((microtime(true) - $startTime) * 1000);
// Check if grounding was used
$groundingUsed = $this->checkGroundingUsed($response);
return [
'content' => $response->text(),
'model' => $config['model'] ?? $this->defaultModel,
'latency_ms' => $latency,
'grounding_used' => $groundingUsed,
'usage' => $usage,
];
}
/**
* Generate article with structured JSON output
*/
public function generateArticleStructured(string $templateFile, array $config): array
{
$startTime = microtime(true);
// Load template
$template = $this->loadTemplate($templateFile);
// Build dynamic variables
$variables = $this->buildArticleVariables($config);
// Define JSON structure for article
$jsonSchema = $config['json_schema'] ?? [
'title' => 'Article title',
'meta_description' => 'SEO meta description',
'content' => 'Full article content in HTML format',
'sections' => [
['heading' => 'Section heading', 'content' => 'Section content']
],
'tags' => ['relevant', 'tags'],
'sources' => [
['title' => 'Source title', 'url' => 'Source URL']
],
];
$schemaJson = json_encode($jsonSchema, JSON_PRETTY_PRINT);
// Combine template with variables and JSON instruction
$systemPrompt = $template . "\n\n" . $variables . "\n\n" .
"IMPORTANT: Respond ONLY with valid JSON using this structure:\n" . $schemaJson;
// Build the model
$model = $this->client->generativeModel(
model: $config['model'] ?? $this->defaultModel
);
$model = $model->withSystemInstruction(Content::parse($systemPrompt));
// Enable JSON output
$model = $model->withGenerationConfig(new GenerationConfig(
maxOutputTokens: $config['max_tokens'] ?? $this->defaultMaxTokens,
responseMimeType: ResponseMimeType::APPLICATION_JSON,
));
// Enable Google Search
if ($config['use_search'] ?? true) {
$model = $model->withTool(new Tool(googleSearch: new GoogleSearch()));
}
$userPrompt = $config['prompt'] ?? 'BEGIN GENERATION NOW.';
$response = $model->generateContent($userPrompt);
$usage = $this->extractUsage($response);
$latency = round((microtime(true) - $startTime) * 1000);
$groundingUsed = $this->checkGroundingUsed($response);
// Parse JSON response
$articleData = null;
try {
$articleData = $response->json();
} catch (Exception $e) {
// Fall back to text if JSON parsing fails
$articleData = ['content' => $response->text(), 'parse_error' => $e->getMessage()];
}
return [
'content' => $response->text(),
'article' => $articleData,
'model' => $config['model'] ?? $this->defaultModel,
'latency_ms' => $latency,
'grounding_used' => $groundingUsed,
'usage' => $usage,
];
}
/**
* Generate multiple articles
*/
public function generateArticleBatch(string $templateFile, array $articles): array
{
$results = [];
foreach ($articles as $index => $config) {
$results[] = [
'index' => $index,
'topic' => $config['topic'] ?? 'Unknown',
'result' => $this->generateArticle($templateFile, $config),
];
}
return $results;
}
// =========================================================================
// ARTICLE REVIEW
// =========================================================================
/**
* Review an article for quality and factual accuracy
*/
public function reviewArticle(string $articleContent, array $options = []): array
{
$startTime = microtime(true);
$criteria = $options['criteria'] ?? [
'grammar_spelling',
'factual_accuracy',
'readability',
'seo_optimization',
'engagement',
'structure',
'tone_consistency',
];
$criteriaList = implode(', ', $criteria);
$systemPrompt = <<<PROMPT
You are an expert editor and content quality analyst. Your task is to thoroughly review articles
and provide detailed, actionable feedback. Use Google Search to verify any factual claims made
in the article. Be thorough but constructive.
PROMPT;
$jsonSchema = [
'overall_score' => 85,
'summary' => 'Brief overall assessment',
'criteria_scores' => [
'example_criterion' => [
'score' => 90,
'feedback' => 'Detailed feedback',
],
],
'strengths' => ['List of article strengths'],
'improvements' => [
[
'priority' => 'high|medium|low',
'issue' => 'Issue description',
'suggestion' => 'How to fix it',
],
],
'fact_check' => [
[
'claim' => 'Claim from the article',
'verified' => true,
'source' => 'Source or note',
],
],
];
$schemaJson = json_encode($jsonSchema, JSON_PRETTY_PRINT);
$userPrompt = <<<PROMPT
Review the following article and provide a comprehensive quality assessment.
ARTICLE TO REVIEW:
{$articleContent}
Evaluate on these criteria: {$criteriaList}
Use Google Search to verify factual claims.
Respond with JSON in this structure:
{$schemaJson}
PROMPT;
$model = $this->client->generativeModel(
model: $options['model'] ?? $this->defaultModel
);
$model = $model->withSystemInstruction(Content::parse($systemPrompt));
$model = $model->withGenerationConfig(new GenerationConfig(
maxOutputTokens: $options['max_tokens'] ?? $this->defaultMaxTokens,
responseMimeType: ResponseMimeType::APPLICATION_JSON,
));
// Enable Google Search for fact-checking
$model = $model->withTool(new Tool(googleSearch: new GoogleSearch()));
$response = $model->generateContent($userPrompt);
$usage = $this->extractUsage($response);
$latency = round((microtime(true) - $startTime) * 1000);
$reviewData = null;
try {
$reviewData = $response->json();
} catch (Exception $e) {
$reviewData = ['content' => $response->text(), 'parse_error' => $e->getMessage()];
}
return [
'content' => $response->text(),
'review' => $reviewData,
'model' => $options['model'] ?? $this->defaultModel,
'latency_ms' => $latency,
'usage' => $usage,
];
}
// =========================================================================
// RESEARCH
// =========================================================================
/**
* Research a topic using Google Search
*/
public function research(string $query, array $options = []): array
{
$startTime = microtime(true);
$depth = $options['depth'] ?? 2;
$depthInstructions = match ($depth) {
1 => 'Provide a quick overview with 3-5 key points.',
2 => 'Provide a comprehensive overview with detailed findings.',
3 => 'Provide an exhaustive analysis covering all aspects.',
default => 'Provide a comprehensive overview.',
};
$systemPrompt = <<<PROMPT
You are a research assistant. Research topics thoroughly using Google Search.
Verify information across multiple sources. Be accurate and cite sources.
PROMPT;
$jsonSchema = [
'topic' => 'The research topic',
'summary' => 'Executive summary',
'key_findings' => [
['finding' => 'Key finding', 'confidence' => 'high|medium|low'],
],
'statistics' => [
['stat' => 'Statistic', 'source' => 'Source'],
],
'sources' => [
['title' => 'Source title', 'url' => 'URL', 'credibility' => 'high|medium|low'],
],
];
$schemaJson = json_encode($jsonSchema, JSON_PRETTY_PRINT);
$userPrompt = <<<PROMPT
Research: {$query}
{$depthInstructions}
Use Google Search to find current and accurate information.
Respond with JSON:
{$schemaJson}
PROMPT;
$model = $this->client->generativeModel(
model: $options['model'] ?? $this->defaultModel
);
$model = $model->withSystemInstruction(Content::parse($systemPrompt));
$model = $model->withGenerationConfig(new GenerationConfig(
maxOutputTokens: $options['max_tokens'] ?? $this->defaultMaxTokens,
responseMimeType: ResponseMimeType::APPLICATION_JSON,
));
$model = $model->withTool(new Tool(googleSearch: new GoogleSearch()));
$response = $model->generateContent($userPrompt);
$usage = $this->extractUsage($response);
$latency = round((microtime(true) - $startTime) * 1000);
$researchData = null;
try {
$researchData = $response->json();
} catch (Exception $e) {
$researchData = ['content' => $response->text(), 'parse_error' => $e->getMessage()];
}
return [
'content' => $response->text(),
'research' => $researchData,
'model' => $options['model'] ?? $this->defaultModel,
'latency_ms' => $latency,
'usage' => $usage,
];
}
// =========================================================================
// UTILITY METHODS
// =========================================================================
/**
* Extract usage information from response
*/
private function extractUsage($response): array
{
$usage = [
'input_tokens' => 0,
'output_tokens' => 0,
'total_tokens' => 0,
];
// Try to get usage metadata from response
if (isset($response->usageMetadata)) {
$usage['input_tokens'] = $response->usageMetadata->promptTokenCount ?? 0;
$usage['output_tokens'] = $response->usageMetadata->candidatesTokenCount ?? 0;
$usage['total_tokens'] = $response->usageMetadata->totalTokenCount ??
($usage['input_tokens'] + $usage['output_tokens']);
}
return $usage;
}
/**
* Check if grounding/search was used in the response
*/
private function checkGroundingUsed($response): bool
{
// Check for grounding metadata in response
if (isset($response->candidates[0]->groundingMetadata)) {
return true;
}
return false;
}
public function estimateTokens(string $text): int
{
return (int) ceil(strlen($text) / 4);
}
public function estimateCost(array $usage, ?string $model = null): array
{
$model = $model ?? $this->defaultModel;
// Gemini pricing per 1M tokens (as of January 2026)
$pricing = [
'gemini-3-pro-preview' => ['input' => 2.00, 'output' => 12.00],
'gemini-3-flash-preview' => ['input' => 0.20, 'output' => 0.80],
'gemini-2.5-flash' => ['input' => 0.15, 'output' => 0.60],
'gemini-2.5-flash-lite' => ['input' => 0.075, 'output' => 0.30],
];
$rates = $pricing[$model] ?? $pricing['gemini-3-pro-preview'];
$inputCost = (($usage['input_tokens'] ?? 0) / 1_000_000) * $rates['input'];
$outputCost = (($usage['output_tokens'] ?? 0) / 1_000_000) * $rates['output'];
return [
'input_cost' => round($inputCost, 6),
'output_cost' => round($outputCost, 6),
'total_cost' => round($inputCost + $outputCost, 6),
'model' => $model,
];
}
public function formatCost(array $usage): string
{
$cost = $this->estimateCost($usage);
$output = "Cost Breakdown:\n";
$output .= " Input: $" . number_format($cost['input_cost'], 4) . "\n";
$output .= " Output: $" . number_format($cost['output_cost'], 4) . "\n";
$output .= " ─────────────\n";
$output .= " Total: $" . number_format($cost['total_cost'], 4) . "\n";
return $output;
}
// =========================================================================
// CONFIGURATION
// =========================================================================
public function setModel(string $model): self
{
$this->defaultModel = $model;
return $this;
}
public function useModel(string $preset): self
{
if (isset(self::MODELS[$preset])) {
$this->defaultModel = self::MODELS[$preset];
}
return $this;
}
public function setMaxTokens(int $tokens): self
{
$this->defaultMaxTokens = $tokens;
return $this;
}
public function getModel(): string
{
return $this->defaultModel;
}
public function getClient()
{
return $this->client;
}
public function getAvailableModels(): array
{
return self::MODELS;
}
/**
* Get model labels for UI dropdowns
*/
public static function getModelLabels(): array
{
return self::MODEL_LABELS;
}
/**
* Get model ID from preset name
*/
public static function getModelId(string $preset): string
{
return self::MODELS[$preset] ?? $preset;
}
}