Files
folio/src/Service/AiService.php
T
cedricAbonnel 40656631ba v1.6.28 : drill-down IP par AS dans stats pays, suppression Répartition par réseau
- Admin stats : clic sur un réseau AS affiche les IPs avec mini sparkline 14 jours + articles/livres consultés
- AccessLogParser : calcul ip_data (daily + top paths) inclus dans le cache stats
- Suppression du tableau statique "Répartition par réseau" (fusionné dans accordéon pays)
- PHP-CS-Fixer appliqué sur l'ensemble des fichiers modifiés

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:59:44 +02:00

226 lines
8.2 KiB
PHP

<?php
declare(strict_types=1);
class AiService
{
private const SYSTEM_CRITIQUE = <<<'PROMPT'
Tu es un relecteur expert de blogs. Analyse l'article ci-dessous et identifie ses faiblesses : arguments insuffisamment étayés, imprécisions, manques de clarté, structure à améliorer, points à développer. Sois constructif et précis. Réponds en markdown avec des sections claires.
PROMPT;
private const SYSTEM_REWRITE = <<<'PROMPT'
Tu es un rédacteur expert. Réécris l'article ci-dessous en améliorant le style, la clarté et la structure, sans modifier le sens ni les faits. Conserve le format markdown, les liens et les références aux images. Réponds uniquement avec l'article réécrit, sans commentaire ni explication.
PROMPT;
private const SYSTEM_ANALYZE = <<<'PROMPT'
Tu es un relecteur et rédacteur expert de blogs. Pour l'article ci-dessous, fais deux choses :
1. Identifie ses faiblesses (arguments faibles, imprécisions, manques de clarté, structure à revoir, points à développer). Sois bref et précis — quelques lignes suffisent.
2. Propose une version améliorée de l'article : meilleur style, clarté, structure. Conserve le sens, les faits, le format markdown, les liens et les références aux images.
Réponds EXACTEMENT dans ce format (les deux séparateurs doivent être présents tels quels) :
===CRITIQUE===
[ton analyse ici]
===REWRITE===
[l'article réécrit ici]
PROMPT;
private string $apiKey;
private string $model;
private string $provider;
public function __construct()
{
require_once BASE_PATH . '/src/SiteSettings.php';
$this->provider = aiProvider();
$this->model = aiModel();
$this->apiKey = $_ENV['ANTHROPIC_API_KEY'] ?? getenv('ANTHROPIC_API_KEY') ?: '';
}
public function isConfigured(): bool
{
if ($this->provider === 'claude_code') {
return is_executable('/usr/local/bin/claude');
}
return $this->apiKey !== '';
}
/** @return array{ok: bool, text?: string, error?: string} */
public function query(string $action, string $title, string $content): array
{
$content = mb_substr(trim($content), 0, 8000);
if ($content === '') {
return ['ok' => false, 'error' => "Contenu de l'article vide"];
}
$userMsg = $title !== '' ? "# {$title}\n\n{$content}" : $content;
if ($action === 'analyze') {
$raw = $this->provider === 'claude_code'
? $this->queryClaudeCode(self::SYSTEM_ANALYZE, $userMsg)
: $this->queryAnthropicRaw(self::SYSTEM_ANALYZE, $userMsg, 4096);
if (!$raw['ok']) {
return $raw;
}
return $this->parseAnalyzeResponse($raw['text'] ?? '');
}
$systemPrompt = match ($action) {
'critique' => self::SYSTEM_CRITIQUE,
'rewrite' => self::SYSTEM_REWRITE,
default => null,
};
if ($systemPrompt === null) {
return ['ok' => false, 'error' => 'Action inconnue'];
}
if ($this->provider === 'claude_code') {
return $this->queryClaudeCode($systemPrompt, $userMsg);
}
return $this->queryAnthropic($action, $systemPrompt, $userMsg);
}
/** @return array{ok: bool, critique?: string, rewrite?: string, error?: string} */
private function parseAnalyzeResponse(string $text): array
{
$parts = preg_split('/===CRITIQUE===|===REWRITE===/', $text);
if (count($parts) < 3) {
// Fallback : pas de séparateurs trouvés, on met tout en critique
return ['ok' => true, 'critique' => trim($text), 'rewrite' => ''];
}
return [
'ok' => true,
'critique' => trim($parts[1]),
'rewrite' => trim($parts[2]),
];
}
/** @return array{ok: bool, text?: string, error?: string} */
private function queryAnthropicRaw(string $systemPrompt, string $userMsg, int $maxTokens): array
{
if ($this->apiKey === '') {
return ['ok' => false, 'error' => 'Clé Anthropic non configurée (ANTHROPIC_API_KEY manquante dans .env)'];
}
$payload = json_encode([
'model' => $this->model,
'max_tokens' => $maxTokens,
'system' => $systemPrompt,
'messages' => [['role' => 'user', 'content' => $userMsg]],
]);
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 90,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . $this->apiKey,
'anthropic-version: 2023-06-01',
'Content-Type: application/json',
],
]);
$resp = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($err !== '') {
return ['ok' => false, 'error' => 'Erreur réseau : ' . $err];
}
$data = json_decode((string) $resp, true);
if ($http !== 200) {
return ['ok' => false, 'error' => $data['error']['message'] ?? ('Anthropic HTTP ' . $http)];
}
return ['ok' => true, 'text' => $data['content'][0]['text'] ?? ''];
}
/** @return array{ok: bool, text?: string, error?: string} */
private function queryAnthropic(string $action, string $systemPrompt, string $userMsg): array
{
if ($this->apiKey === '') {
return ['ok' => false, 'error' => 'Clé Anthropic non configurée (ANTHROPIC_API_KEY manquante dans .env)'];
}
$maxTokens = ($action === 'rewrite') ? 4096 : 1200;
$payload = json_encode([
'model' => $this->model,
'max_tokens' => $maxTokens,
'system' => $systemPrompt,
'messages' => [['role' => 'user', 'content' => $userMsg]],
]);
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . $this->apiKey,
'anthropic-version: 2023-06-01',
'Content-Type: application/json',
],
]);
$resp = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($err !== '') {
return ['ok' => false, 'error' => 'Erreur réseau : ' . $err];
}
$data = json_decode((string) $resp, true);
if ($http !== 200) {
$msg = $data['error']['message'] ?? ('Anthropic HTTP ' . $http);
return ['ok' => false, 'error' => $msg];
}
$text = $data['content'][0]['text'] ?? '';
return ['ok' => true, 'text' => $text];
}
/** @return array{ok: bool, text?: string, error?: string} */
private function queryClaudeCode(string $systemPrompt, string $userMsg): array
{
$bin = '/usr/local/bin/claude';
if (!is_executable($bin)) {
return ['ok' => false, 'error' => 'Claude Code CLI introuvable (/usr/local/bin/claude)'];
}
$prompt = $systemPrompt . "\n\n" . $userMsg;
$cmd = $bin . ' --print ' . escapeshellarg($prompt) . ' 2>&1';
$env = ['HOME' => '/var/lib/claude-www', 'PATH' => '/usr/local/bin:/usr/bin:/bin'];
$desc = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = proc_open($cmd, $desc, $pipes, '/tmp', $env);
if (!is_resource($proc)) {
return ['ok' => false, 'error' => 'proc_open échoué'];
}
fclose($pipes[0]);
$out = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($proc);
if ($code !== 0) {
return ['ok' => false, 'error' => 'Claude Code exit ' . $code . ' : ' . trim((string)$out)];
}
return ['ok' => true, 'text' => trim((string)$out)];
}
}