From 88115605238b8c1f1a918e87178a89ac263523fc Mon Sep 17 00:00:00 2001 From: Adam Tao Date: Sat, 30 May 2026 12:33:16 +0800 Subject: [PATCH 1/5] [LLM Classification] Skip duplicate LLM calls on no-op feed updates Some RSS sources re-publish existing articles with no semantic change (reformatted author, tweaked date, new enclosure attribute, etc.). FreshRSS detects these as updated entries via hash mismatch and calls EntryBeforeInsert again, which previously triggered another LLM API call for every such pseudo-update. The extension now stores a SHA-1 of the prompt it sent (plus the exact list of tags it assigned) on each classified entry, under the 'llm_classification' attribute namespace. On a feed update of an already-classified entry: - if the new prompt hashes to the same value, the prior tags are restored and no LLM call is made; - otherwise, behaviour follows the new 'Re-classify when content changes' toggle (default on): call the LLM and refresh, or keep the prior tags untouched. When re-classifying, the prior tag list is used to remove only those exact tags (instead of the previous prefix-based heuristic), so manual tags sharing the prefix are preserved. --- xExtension-LlmClassification/README.md | 13 +- xExtension-LlmClassification/configure.phtml | 9 + xExtension-LlmClassification/extension.php | 176 +++++++++++++++---- xExtension-LlmClassification/i18n/en/ext.php | 2 + xExtension-LlmClassification/i18n/fr/ext.php | 2 + 5 files changed, 170 insertions(+), 32 deletions(-) diff --git a/xExtension-LlmClassification/README.md b/xExtension-LlmClassification/README.md index 0b7fd21b..4e774798 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 articles that the feed republishes without any prompt-relevant change, avoiding duplicate LLM API calls ## Requirements @@ -63,13 +64,21 @@ The **user prompt** is an editable template. The following placeholders are repl **Search filters** (one per line): Only entries matching at least one filter are classified. Uses [FreshRSS Boolean search syntax](https://freshrss.github.io/FreshRSS/en/users/10_filter.html). Leave empty to classify all entries. +**Re-classify when content changes** (default: on): When a feed republishes an existing article, FreshRSS treats it as updated even if nothing semantically changed (e.g. a tweaked date, a reformatted author, a new enclosure attribute). To avoid wasted API calls, the extension stores a hash of the prompt it sent for each classified entry. On a feed update: + +- If the prompt would be identical, the previous tags are restored and the LLM is **not** called. +- If the prompt has actually changed and this option is **on**, the LLM is called again to refresh the tags. +- If the prompt has changed and this option is **off**, the previous tags are kept and the LLM is **not** called. + ## How it works 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 +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 and the list of LLM-assigned tags are stored on the entry so future updates can be deduplicated ## Changelog diff --git a/xExtension-LlmClassification/configure.phtml b/xExtension-LlmClassification/configure.phtml index 17f26194..34c56834 100644 --- a/xExtension-LlmClassification/configure.phtml +++ b/xExtension-LlmClassification/configure.phtml @@ -132,6 +132,15 @@ +
+ +
+ getUserConfigurationBool('reclassify_on_change') ?? true) ? 'checked' : '' ?> /> +

+
+
+
diff --git a/xExtension-LlmClassification/extension.php b/xExtension-LlmClassification/extension.php index 66662af2..566190e8 100644 --- a/xExtension-LlmClassification/extension.php +++ b/xExtension-LlmClassification/extension.php @@ -10,6 +10,9 @@ 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_NAMESPACE = 'llm_classification'; + private const ATTRIBUTE_KEY_PROMPT_HASH = 'prompt_hash'; + private const ATTRIBUTE_KEY_TAGS = 'tags'; public string $user_prompt = ''; @@ -57,6 +60,9 @@ public function init(): void { if ($this->getUserConfigurationString('search_filter') === null) { $this->setUserConfigurationValue('search_filter', ''); } + if ($this->getUserConfigurationBool('reclassify_on_change') === null) { + $this->setUserConfigurationValue('reclassify_on_change', true); + } } #[\Override] @@ -89,6 +95,9 @@ public function handleConfigureAction(): void { $this->setUserConfigurationValue('search_filter', trim(Minz_Request::paramString('search_filter', plaintext: true))); + + $this->setUserConfigurationValue('reclassify_on_change', + Minz_Request::paramBoolean('reclassify_on_change')); } $this->user_prompt = ''; @@ -337,48 +346,134 @@ private function callLlm(string $systemPrompt, string $userPrompt): ?array { /** * Apply classification results to an entry. + * * @param array $classification + * @param list|null $previousLlmTags Exact list of tags previously assigned by this extension. + * When provided, these tags are removed from the entry + * before re-adding new ones (used on the update path). + * When null, falls back to prefix-based removal for + * backwards compatibility on the insert path. + * @return array{entry: FreshRSS_Entry, llm_tags: list} The entry with merged tags and the + * new list of LLM-assigned tags. */ - 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 !== '') - : []; + private function applyClassification(FreshRSS_Entry $entry, array $classification, ?array $previousLlmTags = null): array { + $llmTags = []; + if (!is_array($classification['tags'] ?? null)) { + return ['entry' => $entry, 'llm_tags' => $llmTags]; + } - $existingTags = $entry->tags(); + $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 !== '') + : []; - if ($removeOldTags && $prefix !== '') { - $existingTags = array_values(array_filter( - $existingTags, - static fn(string $tag) => !str_starts_with($tag, $prefix) - )); - } + $existingTags = $entry->tags(); - $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'); + if ($previousLlmTags !== null && $previousLlmTags !== []) { + $existingTags = array_values(array_filter( + $existingTags, + static fn(string $tag) => !in_array($tag, $previousLlmTags, true) + )); + } elseif ($previousLlmTags === null && $prefix !== '') { + $existingTags = array_values(array_filter( + $existingTags, + static fn(string $tag) => !str_starts_with($tag, $prefix) + )); + } + + 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; } + $llmTags[] = htmlspecialchars($prefix . $tag, ENT_COMPAT, 'UTF-8'); + } + $llmTags = array_values(array_unique($llmTags)); + + $entry->_tags(array_values(array_unique(array_merge($existingTags, $llmTags)))); + + return ['entry' => $entry, 'llm_tags' => $llmTags]; + } + + /** + * Look up the previously stored classification for an entry being updated. + * + * Returns the prompt hash and the exact list of tags this extension assigned the last time + * the entry was classified. Returns null if the entry was never classified by this extension + * (e.g., it predates the extension being enabled, or the namespaced attribute is missing). + * + * @return array{hash: string, llm_tags: list}|null + * @throws Minz_ConfigurationNamespaceException + * @throws Minz_PDOConnectionException + */ + private function loadPreviousClassification(FreshRSS_Entry $entry): ?array { + $feedId = $entry->feedId(); + $guid = $entry->guid(); + if ($feedId <= 0 || $guid === '') { + return null; + } + + $entryDAO = FreshRSS_Factory::createEntryDao(); + $previous = $entryDAO->searchByGuid($feedId, $guid); + if ($previous === null) { + return null; + } - $entry->_tags(array_values(array_unique(array_merge($existingTags, $newTags)))); + $namespaced = $previous->attributeArray(self::ATTRIBUTE_NAMESPACE); + if ($namespaced === null) { + return null; + } + + $previousHash = $namespaced[self::ATTRIBUTE_KEY_PROMPT_HASH] ?? null; + if (!is_string($previousHash) || $previousHash === '') { + return null; } + $storedTags = $namespaced[self::ATTRIBUTE_KEY_TAGS] ?? null; + $llmTags = is_array($storedTags) + ? array_values(array_filter( + array_map(static fn($t) => is_string($t) ? $t : '', $storedTags), + static fn(string $t) => $t !== '' + )) + : []; + + return ['hash' => $previousHash, 'llm_tags' => $llmTags]; + } + + /** + * Reuse a previous classification on an updated entry: merge the prior LLM tags back into the + * (otherwise fresh) entry and re-attach the namespaced classification attribute so it survives + * the upcoming `updateEntry` write. + * + * @param list $previousLlmTags + */ + private function reusePreviousClassification(FreshRSS_Entry $entry, array $previousLlmTags, string $previousHash): FreshRSS_Entry { + if ($previousLlmTags !== []) { + $existingTags = $entry->tags(); + $entry->_tags(array_values(array_unique(array_merge($existingTags, $previousLlmTags)))); + } + $entry->_attribute(self::ATTRIBUTE_NAMESPACE, [ + self::ATTRIBUTE_KEY_PROMPT_HASH => $previousHash, + self::ATTRIBUTE_KEY_TAGS => $previousLlmTags, + ]); return $entry; } /** - * Hook for EntryBeforeInsert: classify a new entry. + * Hook for EntryBeforeInsert: classify a new entry, or reuse the prior classification when an + * existing entry is detected as updated by FreshRSS but its classification-relevant inputs have + * not changed (or the user opted out of re-classifying updates). This avoids hitting the LLM + * every time a feed publishes a no-op refresh of an article. + * + * @throws Minz_ConfigurationNamespaceException + * @throws Minz_PDOConnectionException * @throws Minz_PermissionDeniedException */ public function classifyEntry(FreshRSS_Entry $entry): FreshRSS_Entry { @@ -398,11 +493,32 @@ public function classifyEntry(FreshRSS_Entry $entry): FreshRSS_Entry { return $entry; } + $promptHash = sha1($systemPrompt . "\n" . $userPrompt); + + $previous = null; + if ($entry->isUpdated() === true) { + $previous = $this->loadPreviousClassification($entry); + if ($previous !== null) { + $reclassifyOnChange = $this->getUserConfigurationBool('reclassify_on_change') ?? true; + $promptUnchanged = hash_equals($previous['hash'], $promptHash); + if ($promptUnchanged || !$reclassifyOnChange) { + return $this->reusePreviousClassification($entry, $previous['llm_tags'], $previous['hash']); + } + } + } + $classification = $this->callLlm($systemPrompt, $userPrompt); if ($classification === null) { return $entry; } - return $this->applyClassification($entry, $classification, removeOldTags: true); + $previousLlmTags = $previous !== null ? $previous['llm_tags'] : null; + $result = $this->applyClassification($entry, $classification, $previousLlmTags); + $entry = $result['entry']; + $entry->_attribute(self::ATTRIBUTE_NAMESPACE, [ + self::ATTRIBUTE_KEY_PROMPT_HASH => $promptHash, + self::ATTRIBUTE_KEY_TAGS => $result['llm_tags'], + ]); + return $entry; } } diff --git a/xExtension-LlmClassification/i18n/en/ext.php b/xExtension-LlmClassification/i18n/en/ext.php index c78a467a..4c690362 100644 --- a/xExtension-LlmClassification/i18n/en/ext.php +++ b/xExtension-LlmClassification/i18n/en/ext.php @@ -36,6 +36,8 @@ 'title' => 'Conditions for tagging', 'search' => 'Search filters', 'search_help' => 'Only classify entries matching at least one of these filters. Leave empty to classify all entries.', + 'reclassify_on_change' => 'Re-classify when content changes', + 'reclassify_on_change_help' => 'When a feed re-publishes an existing article, call the LLM again only if the prompt (title, content, etc.) has actually changed. When disabled, previously-classified articles are never re-classified on update. Articles updated without prompt changes always reuse their previous tags without calling the LLM, regardless of this setting.', ), 'default_prompt' => 'Classify the following article. diff --git a/xExtension-LlmClassification/i18n/fr/ext.php b/xExtension-LlmClassification/i18n/fr/ext.php index 4c13bc2e..87bfe1ca 100644 --- a/xExtension-LlmClassification/i18n/fr/ext.php +++ b/xExtension-LlmClassification/i18n/fr/ext.php @@ -36,6 +36,8 @@ 'title' => 'Conditions pour l’étiquetage', 'search' => 'Filtres de recherche', 'search_help' => 'Classifier uniquement les articles correspondant à au moins un de ces filtres. Laisser vide pour classifier tous les articles.', + 'reclassify_on_change' => 'Re-classifier lorsque le contenu change', + 'reclassify_on_change_help' => 'Lorsqu’un flux republie un article existant, ne rappeler le LLM que si l’invite (titre, contenu, etc.) a réellement changé. Si désactivé, les articles déjà classifiés ne sont jamais re-classifiés lors d’une mise à jour. Les articles mis à jour sans changement d’invite réutilisent toujours leurs tags précédents sans appeler le LLM, indépendamment de ce réglage.', ), 'default_prompt' => 'Classifie l’article suivant. From 92c922569f13f81f20098d46d4c0a284e94b6f15 Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Mon, 29 Jun 2026 16:41:17 +0200 Subject: [PATCH 2/5] Simplification * Remove option (for now) * Re-use existing information instead of additional attribute * Reduce code --- xExtension-LlmClassification/README.md | 14 +- xExtension-LlmClassification/configure.phtml | 8 - xExtension-LlmClassification/extension.php | 213 +++++++------------ xExtension-LlmClassification/i18n/en/ext.php | 2 - xExtension-LlmClassification/i18n/fr/ext.php | 4 +- xExtension-LlmClassification/metadata.json | 2 +- 6 files changed, 77 insertions(+), 166 deletions(-) diff --git a/xExtension-LlmClassification/README.md b/xExtension-LlmClassification/README.md index 4e774798..3b6f367e 100644 --- a/xExtension-LlmClassification/README.md +++ b/xExtension-LlmClassification/README.md @@ -11,7 +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 articles that the feed republishes without any prompt-relevant change, avoiding duplicate LLM API calls +- Skips re-classifying updated articles without any prompt-relevant change, avoiding redundant LLM API calls ## Requirements @@ -64,12 +64,6 @@ The **user prompt** is an editable template. The following placeholders are repl **Search filters** (one per line): Only entries matching at least one filter are classified. Uses [FreshRSS Boolean search syntax](https://freshrss.github.io/FreshRSS/en/users/10_filter.html). Leave empty to classify all entries. -**Re-classify when content changes** (default: on): When a feed republishes an existing article, FreshRSS treats it as updated even if nothing semantically changed (e.g. a tweaked date, a reformatted author, a new enclosure attribute). To avoid wasted API calls, the extension stores a hash of the prompt it sent for each classified entry. On a feed update: - -- If the prompt would be identical, the previous tags are restored and the LLM is **not** called. -- If the prompt has actually changed and this option is **on**, the LLM is called again to refresh the tags. -- If the prompt has changed and this option is **off**, the previous tags are kept and the LLM is **not** called. - ## How it works 1. A new article arrives in FreshRSS @@ -78,8 +72,4 @@ The **user prompt** is an editable template. The following placeholders are repl 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 and the list of LLM-assigned tags are stored on the entry so future updates can be deduplicated - -## Changelog - -- 0.1: Initial version +7. The prompt hash is stored along the entry diff --git a/xExtension-LlmClassification/configure.phtml b/xExtension-LlmClassification/configure.phtml index 8a974233..310d8aaa 100644 --- a/xExtension-LlmClassification/configure.phtml +++ b/xExtension-LlmClassification/configure.phtml @@ -141,14 +141,6 @@
-
- -
- getUserConfigurationBool('reclassify_on_change') ?? true) ? 'checked' : '' ?> /> -

-
-
diff --git a/xExtension-LlmClassification/extension.php b/xExtension-LlmClassification/extension.php index e73e6cab..c427e7de 100644 --- a/xExtension-LlmClassification/extension.php +++ b/xExtension-LlmClassification/extension.php @@ -10,9 +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_NAMESPACE = 'llm_classification'; - private const ATTRIBUTE_KEY_PROMPT_HASH = 'prompt_hash'; - private const ATTRIBUTE_KEY_TAGS = 'tags'; + private const ATTRIBUTE_PROMPT_HASH = 'prompt_hash'; public string $user_prompt = ''; @@ -63,9 +61,6 @@ public function init(): void { if ($this->getUserConfigurationBool('allow_thinking') === null) { $this->setUserConfigurationValue('allow_thinking', true); } - if ($this->getUserConfigurationBool('reclassify_on_change') === null) { - $this->setUserConfigurationValue('reclassify_on_change', true); - } } #[\Override] @@ -90,7 +85,6 @@ public function handleConfigureAction(): void { $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->setUserConfigurationValue('reclassify_on_change', Minz_Request::paramBoolean('reclassify_on_change')); } $this->user_prompt = ''; @@ -356,134 +350,67 @@ private function callLlm(string $systemPrompt, string $userPrompt): ?array { /** * Apply classification results to an entry. - * * @param array $classification - * @param list|null $previousLlmTags Exact list of tags previously assigned by this extension. - * When provided, these tags are removed from the entry - * before re-adding new ones (used on the update path). - * When null, falls back to prefix-based removal for - * backwards compatibility on the insert path. - * @return array{entry: FreshRSS_Entry, llm_tags: list} The entry with merged tags and the - * new list of LLM-assigned tags. */ - private function applyClassification(FreshRSS_Entry $entry, array $classification, ?array $previousLlmTags = null): array { - $llmTags = []; - if (!is_array($classification['tags'] ?? null)) { - return ['entry' => $entry, 'llm_tags' => $llmTags]; - } - - $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 !== '') - : []; + 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 ($previousLlmTags !== null && $previousLlmTags !== []) { - $existingTags = array_values(array_filter( - $existingTags, - static fn(string $tag) => !in_array($tag, $previousLlmTags, true) - )); - } elseif ($previousLlmTags === null && $prefix !== '') { - $existingTags = array_values(array_filter( - $existingTags, - static fn(string $tag) => !str_starts_with($tag, $prefix) - )); - } + $existingTags = $entry->tags(); - foreach ($classification['tags'] as $tag) { - if (!is_string($tag)) { - continue; - } - $tag = trim($tag); - if ($tag === '') { - continue; + if ($removeOldTags && $prefix !== '') { + $existingTags = array_values(array_filter( + $existingTags, + static fn(string $tag) => !str_starts_with($tag, $prefix) + )); } - if (!empty($allowedTags) && !in_array($tag, $allowedTags, true)) { - continue; + + $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'); } - $llmTags[] = htmlspecialchars($prefix . $tag, ENT_COMPAT, 'UTF-8'); - } - $llmTags = array_values(array_unique($llmTags)); - $entry->_tags(array_values(array_unique(array_merge($existingTags, $llmTags)))); + $entry->_tags(array_values(array_unique(array_merge($existingTags, $newTags)))); + } - return ['entry' => $entry, 'llm_tags' => $llmTags]; + return $entry; } /** - * Look up the previously stored classification for an entry being updated. - * - * Returns the prompt hash and the exact list of tags this extension assigned the last time - * the entry was classified. Returns null if the entry was never classified by this extension - * (e.g., it predates the extension being enabled, or the namespaced attribute is missing). - * - * @return array{hash: string, llm_tags: list}|null - * @throws Minz_ConfigurationNamespaceException - * @throws Minz_PDOConnectionException + * Look up the currently stored entry for an entry being updated. + * @throws Minz_PermissionDeniedException */ - private function loadPreviousClassification(FreshRSS_Entry $entry): ?array { - $feedId = $entry->feedId(); - $guid = $entry->guid(); - if ($feedId <= 0 || $guid === '') { - return null; - } - - $entryDAO = FreshRSS_Factory::createEntryDao(); - $previous = $entryDAO->searchByGuid($feedId, $guid); - if ($previous === null) { - return null; - } - - $namespaced = $previous->attributeArray(self::ATTRIBUTE_NAMESPACE); - if ($namespaced === null) { + private function loadExistingEntry(FreshRSS_Entry $incomingEntry): ?FreshRSS_Entry { + $feedId = $incomingEntry->feedId(); + $guid = $incomingEntry->guid(); + if ($feedId === 0 || $guid === '') { return null; } - - $previousHash = $namespaced[self::ATTRIBUTE_KEY_PROMPT_HASH] ?? null; - if (!is_string($previousHash) || $previousHash === '') { + 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; } - - $storedTags = $namespaced[self::ATTRIBUTE_KEY_TAGS] ?? null; - $llmTags = is_array($storedTags) - ? array_values(array_filter( - array_map(static fn($t) => is_string($t) ? $t : '', $storedTags), - static fn(string $t) => $t !== '' - )) - : []; - - return ['hash' => $previousHash, 'llm_tags' => $llmTags]; } /** - * Reuse a previous classification on an updated entry: merge the prior LLM tags back into the - * (otherwise fresh) entry and re-attach the namespaced classification attribute so it survives - * the upcoming `updateEntry` write. - * - * @param list $previousLlmTags - */ - private function reusePreviousClassification(FreshRSS_Entry $entry, array $previousLlmTags, string $previousHash): FreshRSS_Entry { - if ($previousLlmTags !== []) { - $existingTags = $entry->tags(); - $entry->_tags(array_values(array_unique(array_merge($existingTags, $previousLlmTags)))); - } - $entry->_attribute(self::ATTRIBUTE_NAMESPACE, [ - self::ATTRIBUTE_KEY_PROMPT_HASH => $previousHash, - self::ATTRIBUTE_KEY_TAGS => $previousLlmTags, - ]); - return $entry; - } - - /** - * Hook for EntryBeforeInsert: classify a new entry, or reuse the prior classification when an - * existing entry is detected as updated by FreshRSS but its classification-relevant inputs have - * not changed (or the user opted out of re-classifying updates). This avoids hitting the LLM - * every time a feed publishes a no-op refresh of an article. - * - * @throws Minz_ConfigurationNamespaceException - * @throws Minz_PDOConnectionException + * Hook for EntryBeforeInsert: classify a new entry. * @throws Minz_PermissionDeniedException */ public function classifyEntry(FreshRSS_Entry $entry): FreshRSS_Entry { @@ -499,36 +426,42 @@ public function classifyEntry(FreshRSS_Entry $entry): FreshRSS_Entry { $systemPrompt = $this->getSystemPrompt(); $userPrompt = $this->buildUserPrompt($entry); - if ($userPrompt === '') { - return $entry; - } - - $promptHash = sha1($systemPrompt . "\n" . $userPrompt); - - $previous = null; - if ($entry->isUpdated() === true) { - $previous = $this->loadPreviousClassification($entry); - if ($previous !== null) { - $reclassifyOnChange = $this->getUserConfigurationBool('reclassify_on_change') ?? true; - $promptUnchanged = hash_equals($previous['hash'], $promptHash); - if ($promptUnchanged || !$reclassifyOnChange) { - return $this->reusePreviousClassification($entry, $previous['llm_tags'], $previous['hash']); + $classification = null; + + assert($this->getEntrypoint() !== ''); // For PHPStan // TODO: Fix in parent method + + 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; + $incomingPromptHash = sha1($systemPrompt . $userPrompt); + 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); if ($classification === null) { - return $entry; + $entry->_attribute($this->getEntrypoint(), [ + self::ATTRIBUTE_PROMPT_HASH => sha1($systemPrompt . $userPrompt), + ]); + if ($userPrompt === '') { + return $entry; + } + $classification = $this->callLlm($systemPrompt, $userPrompt) ?? []; } - $previousLlmTags = $previous !== null ? $previous['llm_tags'] : null; - $result = $this->applyClassification($entry, $classification, $previousLlmTags); - $entry = $result['entry']; - $entry->_attribute(self::ATTRIBUTE_NAMESPACE, [ - self::ATTRIBUTE_KEY_PROMPT_HASH => $promptHash, - self::ATTRIBUTE_KEY_TAGS => $result['llm_tags'], - ]); - return $entry; + return $this->applyClassification($entry, $classification, removeOldTags: true); } } diff --git a/xExtension-LlmClassification/i18n/en/ext.php b/xExtension-LlmClassification/i18n/en/ext.php index 5fa96ed7..60ac02bd 100644 --- a/xExtension-LlmClassification/i18n/en/ext.php +++ b/xExtension-LlmClassification/i18n/en/ext.php @@ -38,8 +38,6 @@ 'title' => 'Conditions for tagging', 'search' => 'Search filters', 'search_help' => 'Only classify entries matching at least one of these filters. Leave empty to classify all entries.', - 'reclassify_on_change' => 'Re-classify when content changes', - 'reclassify_on_change_help' => 'When a feed re-publishes an existing article, call the LLM again only if the prompt (title, content, etc.) has actually changed. When disabled, previously-classified articles are never re-classified on update. Articles updated without prompt changes always reuse their previous tags without calling the LLM, regardless of this setting.', ), 'default_prompt' => 'Classify the following article. diff --git a/xExtension-LlmClassification/i18n/fr/ext.php b/xExtension-LlmClassification/i18n/fr/ext.php index 0220c31b..a2436d61 100644 --- a/xExtension-LlmClassification/i18n/fr/ext.php +++ b/xExtension-LlmClassification/i18n/fr/ext.php @@ -38,8 +38,6 @@ 'title' => 'Conditions pour l’étiquetage', 'search' => 'Filtres de recherche', 'search_help' => 'Classifier uniquement les articles correspondant à au moins un de ces filtres. Laisser vide pour classifier tous les articles.', - 'reclassify_on_change' => 'Re-classifier lorsque le contenu change', - 'reclassify_on_change_help' => 'Lorsqu’un flux republie un article existant, ne rappeler le LLM que si l’invite (titre, contenu, etc.) a réellement changé. Si désactivé, les articles déjà classifiés ne sont jamais re-classifiés lors d’une mise à jour. Les articles mis à jour sans changement d’invite réutilisent toujours leurs tags précédents sans appeler le LLM, indépendamment de ce réglage.', ), 'default_prompt' => 'Classifie l’article suivant. @@ -48,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" From 46a4eaf413d01e8c34c84f2307fafc7ad71aac28 Mon Sep 17 00:00:00 2001 From: Adam Tao Date: Fri, 3 Jul 2026 06:47:22 +0800 Subject: [PATCH 3/5] fix: prompt_hash is cleared for updated entries Signed-off-by: Adam Tao --- xExtension-LlmClassification/extension.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/xExtension-LlmClassification/extension.php b/xExtension-LlmClassification/extension.php index c427e7de..4920f2f3 100644 --- a/xExtension-LlmClassification/extension.php +++ b/xExtension-LlmClassification/extension.php @@ -426,6 +426,7 @@ public function classifyEntry(FreshRSS_Entry $entry): FreshRSS_Entry { $systemPrompt = $this->getSystemPrompt(); $userPrompt = $this->buildUserPrompt($entry); + $incomingPromptHash = sha1($systemPrompt . $userPrompt); $classification = null; assert($this->getEntrypoint() !== ''); // For PHPStan // TODO: Fix in parent method @@ -435,7 +436,6 @@ public function classifyEntry(FreshRSS_Entry $entry): FreshRSS_Entry { // Compare prompt hashes for any meaningful change since the last classification $existingPromptHash = $existingEntry?->attributeArray($this->getEntrypoint())[self::ATTRIBUTE_PROMPT_HASH] ?? null; - $incomingPromptHash = sha1($systemPrompt . $userPrompt); if ($existingPromptHash === $incomingPromptHash) { // Re-use existing tags matching the prefix $prefix = $this->getUserConfigurationString('tag_prefix') ?? ''; @@ -452,10 +452,11 @@ public function classifyEntry(FreshRSS_Entry $entry): FreshRSS_Entry { } } + // re-attach the namespaced classification attribute so it survives the upcomming `updateEntry` write + $entry->_attribute($this->getEntrypoint(), [ + self::ATTRIBUTE_PROMPT_HASH => $incomingPromptHash, + ]); if ($classification === null) { - $entry->_attribute($this->getEntrypoint(), [ - self::ATTRIBUTE_PROMPT_HASH => sha1($systemPrompt . $userPrompt), - ]); if ($userPrompt === '') { return $entry; } From 32cb75498cf594a582cac42753f94d1987824189 Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Fri, 3 Jul 2026 22:58:30 +0200 Subject: [PATCH 4/5] Remove PHPStan help --- xExtension-LlmClassification/extension.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/xExtension-LlmClassification/extension.php b/xExtension-LlmClassification/extension.php index 4920f2f3..36872ddc 100644 --- a/xExtension-LlmClassification/extension.php +++ b/xExtension-LlmClassification/extension.php @@ -429,8 +429,6 @@ public function classifyEntry(FreshRSS_Entry $entry): FreshRSS_Entry { $incomingPromptHash = sha1($systemPrompt . $userPrompt); $classification = null; - assert($this->getEntrypoint() !== ''); // For PHPStan // TODO: Fix in parent method - if ($entry->isUpdated()) { $existingEntry = $this->loadExistingEntry($entry); From 2d9dce58e96609536cd11d8c8e876e0b5d493ec3 Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Fri, 3 Jul 2026 23:01:44 +0200 Subject: [PATCH 5/5] Typo --- xExtension-LlmClassification/extension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xExtension-LlmClassification/extension.php b/xExtension-LlmClassification/extension.php index 36872ddc..6a61a5e2 100644 --- a/xExtension-LlmClassification/extension.php +++ b/xExtension-LlmClassification/extension.php @@ -450,7 +450,7 @@ public function classifyEntry(FreshRSS_Entry $entry): FreshRSS_Entry { } } - // re-attach the namespaced classification attribute so it survives the upcomming `updateEntry` write + // re-attach the namespaced classification attribute so it survives the upcoming `updateEntry` write $entry->_attribute($this->getEntrypoint(), [ self::ATTRIBUTE_PROMPT_HASH => $incomingPromptHash, ]);