Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions xExtension-LlmClassification/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
54 changes: 50 additions & 4 deletions xExtension-LlmClassification/extension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';

Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion xExtension-LlmClassification/i18n/fr/ext.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
Date : {date}
URL : {url}
Flux : {feed_name} ({feed_url})
tags existantes : {tags}
Tags existants : {tags}

Contenu :
{content}',
Expand Down
2 changes: 1 addition & 1 deletion xExtension-LlmClassification/metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down