diff --git a/xExtension-LlmClassification/README.md b/xExtension-LlmClassification/README.md index 0b7fd21b..3b6f367e 100644 --- a/xExtension-LlmClassification/README.md +++ b/xExtension-LlmClassification/README.md @@ -11,6 +11,7 @@ It classifies articles on insertion using a customizable prompt, and applies tag - Entry filtering via FreshRSS Boolean search syntax - Response caching to avoid redundant API calls - Content truncation to control token usage +- Skips re-classifying updated articles without any prompt-relevant change, avoiding redundant LLM API calls ## Requirements @@ -68,9 +69,7 @@ The **user prompt** is an editable template. The following placeholders are repl 1. A new article arrives in FreshRSS 2. The extension checks if tag classification is enabled and the article matches the configured search filters 3. The user prompt is built by replacing placeholders with article data -4. The LLM API is called with the system prompt and the user prompt -5. The returned tags are validated (prefix prepended, whitelist enforced) and applied to the article - -## Changelog - -- 0.1: Initial version +4. For updates of previously-classified articles, the new prompt is compared against the stored hash; if unchanged, the previous tags are restored and no API call is made +5. Otherwise, the LLM API is called with the system prompt and the user prompt +6. The returned tags are validated (prefix prepended, whitelist enforced) and applied to the article +7. The prompt hash is stored along the entry diff --git a/xExtension-LlmClassification/extension.php b/xExtension-LlmClassification/extension.php index ac210e08..6a61a5e2 100644 --- a/xExtension-LlmClassification/extension.php +++ b/xExtension-LlmClassification/extension.php @@ -10,6 +10,7 @@ final class LlmClassificationExtension extends Minz_Extension { 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 = ''; @@ -389,6 +390,25 @@ private function applyClassification(FreshRSS_Entry $entry, array $classificatio 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_PermissionDeniedException @@ -406,13 +426,39 @@ public function classifyEntry(FreshRSS_Entry $entry): FreshRSS_Entry { $systemPrompt = $this->getSystemPrompt(); $userPrompt = $this->buildUserPrompt($entry); - if ($userPrompt === '') { - return $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') ?? ''; + $existingTags = $existingEntry->tags(); + if ($prefix !== '') { + $existingTags = array_values(array_filter( + $existingTags, + static fn(string $tag) => str_starts_with($tag, $prefix) + )); + } + $classification = [ + 'tags' => $existingTags, + ]; + } } - $classification = $this->callLlm($systemPrompt, $userPrompt); + // 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) { - return $entry; + if ($userPrompt === '') { + return $entry; + } + $classification = $this->callLlm($systemPrompt, $userPrompt) ?? []; } return $this->applyClassification($entry, $classification, removeOldTags: true); diff --git a/xExtension-LlmClassification/i18n/fr/ext.php b/xExtension-LlmClassification/i18n/fr/ext.php index 4dd08e32..a2436d61 100644 --- a/xExtension-LlmClassification/i18n/fr/ext.php +++ b/xExtension-LlmClassification/i18n/fr/ext.php @@ -46,7 +46,7 @@ Date : {date} URL : {url} Flux : {feed_name} ({feed_url}) -tags existantes : {tags} +Tags existants : {tags} Contenu : {content}', diff --git a/xExtension-LlmClassification/metadata.json b/xExtension-LlmClassification/metadata.json index 666a642e..4ba61679 100644 --- a/xExtension-LlmClassification/metadata.json +++ b/xExtension-LlmClassification/metadata.json @@ -2,7 +2,7 @@ "name": "LLM Classification", "author": "Alkarex", "description": "Tag incoming articles by calling an OpenAI-compatible LLM API", - "version": "0.2", + "version": "0.3", "entrypoint": "LlmClassification", "type": "user", "compatibility": "1.28.2"