mirror of
https://github.com/FreshRSS/Extensions.git
synced 2026-11-04 10:48:40 +00:00
* [LLM Classification] Do not HTML-encode re-used tags again The tags of an entry are stored HTML-encoded: `applyClassification()` encodes the tags returned by the LLM with `htmlspecialchars()`, like SimplePie does for the categories of the feeds. When an already classified entry is updated without any change to its prompt, the extension re-uses the stored tags instead of calling the LLM again, but passed them as-is to `applyClassification()`, which encoded them a second time. So a tag such as `R&D` became `R&D` in the database after classification, then `R&D` after the next such update, and so on. When a list of allowed tags is configured, the encoded tags did not match the list anymore (`R&D` is not `R&D`), and were silently removed from the entry. Decode the stored tags before re-using them, so that they go through the same encoding and allow-list check as the tags returned by the LLM. Decoding first also lets tags match a configured prefix containing such characters. Bump the extension version to 0.3.2. * [LLM Classification] Decode single quotes in re-used tags too
476 lines
17 KiB
PHP
476 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
final class LlmClassificationExtension extends Minz_Extension {
|
|
private const DEFAULT_MODEL = 'gpt-4o-mini';
|
|
private const DEFAULT_TIMEOUT = 30;
|
|
private const DEFAULT_MAX_CONTENT_LENGTH = 4000;
|
|
private const DEFAULT_MAX_TOKENS = 512;
|
|
private const DEFAULT_MAX_RETRIES = 2;
|
|
private const RETRYABLE_HTTP_STATUSES = [429, 500, 502, 503, 504];
|
|
private const PROMPT_FILENAME = 'prompt.md';
|
|
private const ATTRIBUTE_PROMPT_HASH = 'prompt_hash';
|
|
|
|
public string $user_prompt = '';
|
|
|
|
/**
|
|
* @throws FreshRSS_Context_Exception
|
|
*/
|
|
#[\Override]
|
|
public function init(): void {
|
|
if (!FreshRSS_Context::hasSystemConf()) {
|
|
throw new FreshRSS_Context_Exception('System configuration not initialised!');
|
|
}
|
|
$this->registerTranslates();
|
|
$this->registerHook(Minz_HookType::EntryBeforeInsert, $this->classifyEntry(...));
|
|
|
|
if ($this->getUserConfigurationString('api_url') === null) {
|
|
$this->setUserConfigurationValue('api_url', '');
|
|
}
|
|
if ($this->getUserConfigurationString('api_key') === null) {
|
|
$this->setUserConfigurationValue('api_key', '');
|
|
}
|
|
if ($this->getUserConfigurationString('model') === null) {
|
|
$this->setUserConfigurationValue('model', self::DEFAULT_MODEL);
|
|
}
|
|
if ($this->getUserConfigurationInt('max_content_length') === null) {
|
|
$this->setUserConfigurationValue('max_content_length', self::DEFAULT_MAX_CONTENT_LENGTH);
|
|
}
|
|
if ($this->getUserConfigurationInt('timeout') === null) {
|
|
$this->setUserConfigurationValue('timeout', self::DEFAULT_TIMEOUT);
|
|
}
|
|
if ($this->getUserConfigurationBool('enable_tags') === null) {
|
|
$this->setUserConfigurationValue('enable_tags', false);
|
|
}
|
|
if ($this->getUserConfigurationString('tag_prefix') === null) {
|
|
$this->setUserConfigurationValue('tag_prefix', '');
|
|
}
|
|
if ($this->getUserConfigurationString('allowed_tags') === null) {
|
|
$this->setUserConfigurationValue('allowed_tags', '');
|
|
}
|
|
if ($this->getUserConfigurationInt('max_tokens') === null) {
|
|
$this->setUserConfigurationValue('max_tokens', self::DEFAULT_MAX_TOKENS);
|
|
}
|
|
if ($this->getUserConfigurationInt('max_retries') === null) {
|
|
$this->setUserConfigurationValue('max_retries', self::DEFAULT_MAX_RETRIES);
|
|
}
|
|
if ($this->getUserConfigurationString('search_filter') === null) {
|
|
$this->setUserConfigurationValue('search_filter', '');
|
|
}
|
|
if ($this->getUserConfigurationBool('allow_thinking') === null) {
|
|
$this->setUserConfigurationValue('allow_thinking', true);
|
|
}
|
|
}
|
|
|
|
#[\Override]
|
|
public function handleConfigureAction(): void {
|
|
$this->registerTranslates();
|
|
|
|
if (Minz_Request::isPost()) {
|
|
$apiUrl = trim(Minz_Request::paramString('api_url', plaintext: true));
|
|
$apiUrl = preg_replace('#/chat/completions/?$#i', '', $apiUrl) ?? $apiUrl;
|
|
$this->setUserConfigurationValue('api_url', $apiUrl);
|
|
$this->setUserConfigurationValue('api_key', trim(Minz_Request::paramString('api_key', plaintext: true)));
|
|
$this->setUserConfigurationValue('model', trim(Minz_Request::paramString('model', plaintext: true)));
|
|
$userPrompt = trim(Minz_Request::paramString('user_prompt', plaintext: true))
|
|
?: _t('ext.llm_classification.default_prompt');
|
|
$this->saveFile(self::PROMPT_FILENAME, $userPrompt);
|
|
$this->setUserConfigurationValue('max_content_length', Minz_Request::paramInt('max_content_length'));
|
|
$this->setUserConfigurationValue('timeout', Minz_Request::paramInt('timeout') ?: self::DEFAULT_TIMEOUT);
|
|
$this->setUserConfigurationValue('max_tokens', Minz_Request::paramInt('max_tokens'));
|
|
$this->setUserConfigurationValue('max_retries', max(0, min(5, Minz_Request::paramInt('max_retries'))));
|
|
$this->setUserConfigurationValue('enable_tags', Minz_Request::paramBoolean('enable_tags'));
|
|
$this->setUserConfigurationValue('tag_prefix', trim(Minz_Request::paramString('tag_prefix', plaintext: true)));
|
|
$this->setUserConfigurationValue('allowed_tags', trim(Minz_Request::paramString('allowed_tags', plaintext: true)));
|
|
$this->setUserConfigurationValue('search_filter', trim(Minz_Request::paramString('search_filter', plaintext: true)));
|
|
$this->setUserConfigurationValue('allow_thinking', Minz_Request::paramBoolean('allow_thinking'));
|
|
}
|
|
|
|
$this->user_prompt = '';
|
|
if ($this->hasFile(self::PROMPT_FILENAME)) {
|
|
$this->user_prompt = $this->getFile(self::PROMPT_FILENAME) ?? '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the system prompt that constrains the LLM to return a specific JSON structure.
|
|
*/
|
|
public function getSystemPrompt(): string {
|
|
$prompt = <<<'PROMPT'
|
|
You are a classification assistant.
|
|
Your response MUST be valid JSON with exactly this structure: `{"tags": ["tag1", "tag2"]}`
|
|
Rules:
|
|
- "tags" is an array of short classification labels (UTF-8). It can be empty. Each label can only be used once.
|
|
- Do NOT include any text outside the JSON object.
|
|
|
|
PROMPT;
|
|
|
|
$allowedTags = $this->getUserConfigurationString('allowed_tags') ?? '';
|
|
if ($allowedTags !== '') {
|
|
$tagList = array_filter(array_map('trim', explode("\n", $allowedTags)), static fn(string $tag) => $tag !== '');
|
|
if ($tagList !== []) {
|
|
$prompt .= '- You MUST only use tags from this list: ' . implode(', ', $tagList) . "\n";
|
|
}
|
|
}
|
|
|
|
return $prompt;
|
|
}
|
|
|
|
/**
|
|
* Build the user prompt by replacing placeholders with entry values.
|
|
*/
|
|
private function buildUserPrompt(FreshRSS_Entry $entry): string {
|
|
$template = $this->getFile(self::PROMPT_FILENAME) ?? '';
|
|
if ($template === '') {
|
|
return '';
|
|
}
|
|
|
|
$content = strip_tags($entry->content());
|
|
$maxLength = $this->getUserConfigurationInt('max_content_length') ?? self::DEFAULT_MAX_CONTENT_LENGTH;
|
|
if ($maxLength > 0 && mb_strlen($content) > $maxLength) {
|
|
$content = mb_substr($content, 0, $maxLength) . '…';
|
|
}
|
|
|
|
$feed = $entry->feed();
|
|
|
|
$replacements = [
|
|
'{title}' => $entry->title(),
|
|
'{content}' => $content,
|
|
'{author}' => $entry->authors(true),
|
|
'{url}' => $entry->link(),
|
|
'{feed_url}' => $feed !== null ? $feed->url() : '',
|
|
'{feed_name}' => $feed !== null ? $feed->name() : '',
|
|
'{date}' => $entry->date(),
|
|
'{tags}' => $entry->tags(true),
|
|
];
|
|
|
|
return strtr($template, $replacements);
|
|
}
|
|
|
|
/**
|
|
* Check whether the entry matches the configured search filter.
|
|
* Returns true if no filter is configured or the entry matches at least one filter line.
|
|
* @throws Minz_BadRequestException
|
|
*/
|
|
private function entryMatchesSearchFilter(FreshRSS_Entry $entry): bool {
|
|
$filterStr = $this->getUserConfigurationString('search_filter') ?? '';
|
|
if ($filterStr === '') {
|
|
return true;
|
|
}
|
|
$lines = array_filter(array_map('trim', explode("\n", $filterStr)), static fn(string $line) => $line !== '');
|
|
foreach ($lines as $line) {
|
|
$booleanSearch = new FreshRSS_BooleanSearch($line);
|
|
if ($entry->matches($booleanSearch)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Build the response_format payload for the API request.
|
|
* Uses json_schema for strict structured output.
|
|
* When allowed_tags is configured, the schema constrains tag values via an enum.
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function buildResponseFormat(): array {
|
|
$allowedTagsStr = $this->getUserConfigurationString('allowed_tags') ?? '';
|
|
$allowedTags = $allowedTagsStr !== ''
|
|
? array_values(array_filter(array_map('trim', explode("\n", $allowedTagsStr)), static fn(string $tag) => $tag !== ''))
|
|
: [];
|
|
|
|
if ($allowedTags === []) {
|
|
return ['type' => 'json_object'];
|
|
}
|
|
|
|
return [
|
|
'type' => 'json_schema',
|
|
'json_schema' => [
|
|
'name' => 'classification',
|
|
'strict' => true,
|
|
'schema' => [
|
|
'type' => 'object',
|
|
'properties' => [
|
|
'tags' => [
|
|
'type' => 'array',
|
|
'uniqueItems' => true,
|
|
'maxItems' => count($allowedTags),
|
|
'items' => ['type' => 'string', 'enum' => $allowedTags],
|
|
],
|
|
],
|
|
'required' => ['tags'],
|
|
'additionalProperties' => false,
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Determine whether an HTTP failure is transient and worth retrying.
|
|
* @param array{fail:bool,status:int,error:string} $response
|
|
*/
|
|
private static function isRetryableFailure(array $response): bool {
|
|
if (!($response['fail'] ?? false)) {
|
|
return false;
|
|
}
|
|
if (($response['status'] ?? 0) === 0 && ($response['error'] ?? '') !== '') {
|
|
return true;
|
|
}
|
|
return in_array($response['status'] ?? 0, self::RETRYABLE_HTTP_STATUSES, true);
|
|
}
|
|
|
|
/**
|
|
* Call the LLM API and return the parsed classification result.
|
|
* @return array{tags:array<string>}|null
|
|
* @throws Minz_PermissionDeniedException
|
|
*/
|
|
private function callLlm(string $systemPrompt, string $userPrompt): ?array {
|
|
$apiUrl = trim($this->getUserConfigurationString('api_url') ?? '');
|
|
$apiKey = trim($this->getUserConfigurationString('api_key') ?? '');
|
|
$model = trim($this->getUserConfigurationString('model') ?? '') ?: self::DEFAULT_MODEL;
|
|
$timeout = $this->getUserConfigurationInt('timeout') ?? self::DEFAULT_TIMEOUT;
|
|
|
|
if ($apiUrl === '') {
|
|
return null;
|
|
}
|
|
|
|
$url = rtrim($apiUrl, '/') . '/chat/completions';
|
|
|
|
$body = [
|
|
'model' => $model,
|
|
'messages' => [
|
|
['role' => 'system', 'content' => $systemPrompt],
|
|
['role' => 'user', 'content' => $userPrompt],
|
|
],
|
|
'response_format' => $this->buildResponseFormat(),
|
|
];
|
|
|
|
$maxTokens = $this->getUserConfigurationInt('max_tokens') ?? self::DEFAULT_MAX_TOKENS;
|
|
if ($maxTokens > 0) {
|
|
$body['max_completion_tokens'] = $maxTokens;
|
|
}
|
|
|
|
$allowThinking = $this->getUserConfigurationBool('allow_thinking') ?? true;
|
|
if (!$allowThinking) {
|
|
$body['chat_template_kwargs'] = [
|
|
'enable_thinking' => false,
|
|
];
|
|
}
|
|
|
|
$requestBody = json_encode($body, JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
|
|
if ($requestBody === false) {
|
|
Minz_Log::warning('LlmClassification: Failed to encode request body');
|
|
return null;
|
|
}
|
|
|
|
$headers = [
|
|
'Content-Type: application/json',
|
|
'Accept: application/json',
|
|
];
|
|
if ($apiKey !== '') {
|
|
$headers[] = 'Authorization: Bearer ' . $apiKey;
|
|
}
|
|
|
|
$cachePath = CACHE_PATH . '/llm_classification_' . sha1($apiUrl . $apiKey . $model . $requestBody) . '.json';
|
|
|
|
$maxRetries = $this->getUserConfigurationInt('max_retries') ?? self::DEFAULT_MAX_RETRIES;
|
|
$response = null;
|
|
$httpStatus = 0;
|
|
|
|
for ($attempt = 0; $attempt <= $maxRetries; $attempt++) {
|
|
$response = FreshRSS_http_Util::httpGet($url, $cachePath, type: 'json', curl_options: [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => $requestBody,
|
|
CURLOPT_HTTPHEADER => $headers,
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
|
CURLOPT_TIMEOUT => $timeout,
|
|
]);
|
|
|
|
$httpStatus = $response['status'] ?? null;
|
|
$httpStatus = is_numeric($httpStatus) ? (int)$httpStatus : 0;
|
|
|
|
if (!($response['fail'] ?? false) && ($response['body'] ?? '') !== '') {
|
|
break; // Success
|
|
}
|
|
|
|
if ($attempt < $maxRetries && self::isRetryableFailure($response)) {
|
|
$delay = (int)pow(2, $attempt); // Exponential backoff: 1s, 2s, 4s...
|
|
Minz_Log::warning('LlmClassification: API call failed (HTTP ' . $httpStatus
|
|
. (($response['error'] ?? '') !== '' ? '; ' . ($response['error'] ?? '') : '')
|
|
. '), retry ' . ($attempt + 1) . '/' . $maxRetries . ' after ' . $delay . 's');
|
|
sleep($delay);
|
|
@unlink($cachePath);
|
|
continue;
|
|
}
|
|
|
|
Minz_Log::warning('LlmClassification: API call failed for ' . $url
|
|
. ' (HTTP ' . $httpStatus
|
|
. (($response['error'] ?? '') !== '' ? '; ' . ($response['error'] ?? '') : '')
|
|
. '), not retrying');
|
|
return null;
|
|
}
|
|
|
|
if ($response === null || ($response['fail'] ?? false) || !is_string($response['body'] ?? null) || ($response['body'] ?? '') === '') {
|
|
Minz_Log::warning('LlmClassification: API call failed after ' .
|
|
(1 + $maxRetries) . ' attempt(s) for ' . $url . ' (HTTP ' . $httpStatus . ')');
|
|
return null;
|
|
}
|
|
|
|
$responseData = json_decode($response['body'], true);
|
|
if (!is_array($responseData)) {
|
|
Minz_Log::warning('LlmClassification: Invalid JSON response from API! (HTTP ' . $httpStatus . ')');
|
|
return null;
|
|
}
|
|
|
|
$choices = $responseData['choices'] ?? null;
|
|
$choice = is_array($choices) && is_array($choices[0] ?? null) ? $choices[0] : null;
|
|
$content = is_array($choice) && is_array($choice['message'] ?? null) ? ($choice['message']['content'] ?? null) : null;
|
|
if (!is_string($content)) {
|
|
Minz_Log::warning('LlmClassification: Missing `choices[0].message.content` in API response! (HTTP ' . $httpStatus . ')');
|
|
return null;
|
|
}
|
|
|
|
$finishReason = is_array($choice) ? ($choice['finish_reason'] ?? 'stop') : null;
|
|
if ($finishReason !== 'stop') {
|
|
$finishReason = is_scalar($finishReason) ? (string)$finishReason : '?';
|
|
Minz_Log::warning('LlmClassification: API terminated prematurely with finish_reason “' . $finishReason . '”! (HTTP ' . $httpStatus . ')');
|
|
}
|
|
|
|
$classification = json_decode($content, true);
|
|
if (is_array($classification) && is_array($classification['tags'] ?? null)) {
|
|
return [
|
|
'tags' => array_filter($classification['tags'], static fn($tag) => is_string($tag) && $tag !== ''),
|
|
];
|
|
}
|
|
|
|
Minz_Log::warning('LlmClassification: LLM returned invalid JSON structure! `' . ($response['body'] ?? '') . '` (HTTP ' . $httpStatus . ')');
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Apply classification results to an entry.
|
|
* @param array<string,mixed> $classification
|
|
*/
|
|
private function applyClassification(FreshRSS_Entry $entry, array $classification, bool $removeOldTags): FreshRSS_Entry {
|
|
if (is_array($classification['tags'] ?? null)) {
|
|
$prefix = $this->getUserConfigurationString('tag_prefix') ?? '';
|
|
$allowedTagsStr = $this->getUserConfigurationString('allowed_tags') ?? '';
|
|
$allowedTags = $allowedTagsStr !== ''
|
|
? array_filter(array_map('trim', explode("\n", $allowedTagsStr)), static fn(string $tag) => $tag !== '')
|
|
: [];
|
|
|
|
$existingTags = $entry->tags();
|
|
|
|
if ($removeOldTags && $prefix !== '') {
|
|
$existingTags = array_values(array_filter(
|
|
$existingTags,
|
|
static fn(string $tag) => !str_starts_with($tag, $prefix)
|
|
));
|
|
}
|
|
|
|
$newTags = [];
|
|
foreach ($classification['tags'] as $tag) {
|
|
if (!is_string($tag)) {
|
|
continue;
|
|
}
|
|
$tag = trim($tag);
|
|
if ($tag === '') {
|
|
continue;
|
|
}
|
|
if (!empty($allowedTags) && !in_array($tag, $allowedTags, true)) {
|
|
continue;
|
|
}
|
|
$newTags[] = htmlspecialchars($prefix . $tag, ENT_COMPAT, 'UTF-8');
|
|
}
|
|
|
|
$entry->_tags(array_values(array_unique(array_merge($existingTags, $newTags))));
|
|
}
|
|
|
|
return $entry;
|
|
}
|
|
|
|
/**
|
|
* Look up the currently stored entry for an entry being updated.
|
|
* @throws Minz_PermissionDeniedException
|
|
*/
|
|
private function loadExistingEntry(FreshRSS_Entry $incomingEntry): ?FreshRSS_Entry {
|
|
$feedId = $incomingEntry->feedId();
|
|
$guid = $incomingEntry->guid();
|
|
if ($feedId === 0 || $guid === '') {
|
|
return null;
|
|
}
|
|
try {
|
|
$entryDAO = FreshRSS_Factory::createEntryDao();
|
|
return $entryDAO->searchByGuid($feedId, $guid);
|
|
} catch (Minz_ConfigurationNamespaceException | Minz_PDOConnectionException $e) {
|
|
Minz_Log::warning('LlmClassification: Failed to load existing entry for re-classification: ' . $e->getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hook for EntryBeforeInsert: classify a new entry.
|
|
* @throws Minz_BadRequestException
|
|
* @throws Minz_PermissionDeniedException
|
|
*/
|
|
public function classifyEntry(FreshRSS_Entry $entry): FreshRSS_Entry {
|
|
$enableTags = $this->getUserConfigurationBool('enable_tags') ?? false;
|
|
$apiUrl = $this->getUserConfigurationString('api_url') ?? '';
|
|
if (!$enableTags || $apiUrl === '' || !$this->hasFile(self::PROMPT_FILENAME)) {
|
|
return $entry;
|
|
}
|
|
|
|
if (!$this->entryMatchesSearchFilter($entry)) {
|
|
return $entry;
|
|
}
|
|
|
|
$systemPrompt = $this->getSystemPrompt();
|
|
$userPrompt = $this->buildUserPrompt($entry);
|
|
$incomingPromptHash = sha1($systemPrompt . $userPrompt);
|
|
$classification = null;
|
|
|
|
if ($entry->isUpdated()) {
|
|
$existingEntry = $this->loadExistingEntry($entry);
|
|
|
|
// Compare prompt hashes for any meaningful change since the last classification
|
|
$existingPromptHash = $existingEntry?->attributeArray($this->getEntrypoint())[self::ATTRIBUTE_PROMPT_HASH] ?? null;
|
|
if ($existingPromptHash === $incomingPromptHash) {
|
|
// Re-use existing tags matching the prefix
|
|
$prefix = $this->getUserConfigurationString('tag_prefix') ?? '';
|
|
// Decode the stored tags before re-adding them (will be HTML-encoded again in applyClassification)
|
|
$existingTags = array_map(
|
|
static fn(string $tag) => htmlspecialchars_decode($tag, ENT_QUOTES),
|
|
$existingEntry->tags()
|
|
);
|
|
if ($prefix !== '') {
|
|
$existingTags = array_values(array_filter(
|
|
$existingTags,
|
|
static fn(string $tag) => str_starts_with($tag, $prefix)
|
|
));
|
|
// Strip the prefix from tags before re-adding them (will be re-applied in applyClassification)
|
|
$prefixLength = strlen($prefix);
|
|
$existingTags = array_map(static fn(string $tag) => substr($tag, $prefixLength), $existingTags);
|
|
}
|
|
$classification = [
|
|
'tags' => $existingTags,
|
|
];
|
|
}
|
|
}
|
|
|
|
// re-attach the namespaced classification attribute so it survives the upcoming `updateEntry` write
|
|
$entry->_attribute($this->getEntrypoint(), [
|
|
self::ATTRIBUTE_PROMPT_HASH => $incomingPromptHash,
|
|
]);
|
|
if ($classification === null) {
|
|
if ($userPrompt === '') {
|
|
return $entry;
|
|
}
|
|
$classification = $this->callLlm($systemPrompt, $userPrompt) ?? [];
|
|
}
|
|
|
|
return $this->applyClassification($entry, $classification, removeOldTags: true);
|
|
}
|
|
}
|