Skip to content
Open
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
21 changes: 20 additions & 1 deletion changedetectionio/blueprint/settings/templates/settings.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{% extends 'base.html' %}

{% block content %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_simple_field, render_button, render_time_schedule_form %}
{% from '_common_fields.html' import render_common_settings_form %}
<script>
const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode="global-settings")}}";
Expand All @@ -23,6 +23,7 @@
<li class="tab"><a href="#fetching">Fetching</a></li>
<li class="tab"><a href="#filters">Global Filters</a></li>
<li class="tab"><a href="#ui-options">UI Options</a></li>
<li class="tab"><a href="#ai-options"><i data-feather="aperture" style="width: 14px; height: 14px; margin-right: 4px;"></i> AI</a></li>
<li class="tab"><a href="#api">API</a></li>
<li class="tab"><a href="#timedate">Time &amp Date</a></li>
<li class="tab"><a href="#proxies">CAPTCHA &amp; Proxies</a></li>
Expand Down Expand Up @@ -262,6 +263,24 @@ <h4>Chrome Extension</h4>
</div>

</div>
<div class="tab-pane-inner" id="ai-options">
<p><strong>New:</strong> click here (link to changedetection.io tutorial page) find out how to setup and example</p>
<br>
key fields should be some password type field so you can see its set but doesnt contain the key on view and doesnt lose it on save<br>

<div class="pure-control-group inline-radio">
{{ render_simple_field(form.application.form.ai.form.LLM_backend) }}
<span class="pure-form-message-inline">Preferred LLM connection</span>
</div>
<div class="pure-control-group">
{{ render_checkbox_field(form.application.form.ai.form.API_keys.form.openai) }}
<span class="pure-form-message-inline">Go here to read more about OpenAI integration</span>
</div>
<div class="pure-control-group">
{{ render_checkbox_field(form.application.form.ai.form.API_keys.form.gemini) }}
<span class="pure-form-message-inline">Go here to read more about Google Gemini integration</span>
</div>
</div>
<div class="tab-pane-inner" id="proxies">
<div id="recommended-proxy">
<div>
Expand Down
2 changes: 1 addition & 1 deletion changedetectionio/blueprint/tags/templates/edit-tag.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<div class="tabs collapsable">
<ul>
<li class="tab" id=""><a href="#general">General</a></li>
<li class="tab"><a href="#filters-and-triggers">Filters &amp; Triggers</a></li>
<li class="tab"><a href="#filters-and-triggers">AI, Filters &amp; Triggers</a></li>
{% if extra_tab_content %}
<li class="tab"><a href="#extras_tab">{{ extra_tab_content }}</a></li>
{% endif %}
Expand Down
23 changes: 21 additions & 2 deletions changedetectionio/blueprint/ui/edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,8 +312,27 @@ def watch_get_preview_rendered(uuid):
'''For when viewing the "preview" of the rendered text from inside of Edit'''
from flask import jsonify
from changedetectionio.processors.text_json_diff import prepare_filter_prevew
result = prepare_filter_prevew(watch_uuid=uuid, form_data=request.form, datastore=datastore)
return jsonify(result)

watch = datastore.data["watching"].get(uuid)

if not watch:
return jsonify({
"error": "Watch not found",
"code": 400
}), 400

if not watch.history_n:
return jsonify({
"error": "Watch has empty history, at least one fetch of the page is required.",
"code": 400
}), 400
#
try:
result = prepare_filter_prevew(watch_uuid=uuid, form_data=request.form, datastore=datastore)
return jsonify(result)
except Exception as e:
return abort(500, str(e))


@edit_blueprint.route("/highlight_submit_ignore_url", methods=['POST'])
@login_optionally_required
Expand Down
9 changes: 8 additions & 1 deletion changedetectionio/blueprint/ui/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,14 @@ def form_quick_watch_add():

add_paused = request.form.get('edit_and_watch_submit_button') != None
processor = request.form.get('processor', 'text_json_diff')
new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags').strip(), extras={'paused': add_paused, 'processor': processor})
extras = {'paused': add_paused, 'processor': processor}

LLM_prompt = request.form.get('LLM_prompt', '').strip()
if LLM_prompt:
extras['LLM_prompt'] = LLM_prompt
extras['LLM_send_type'] = request.form.get('LLM_send_type', 'text')

new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags').strip(), extras=extras)

if new_uuid:
if add_paused:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,7 @@
<script src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script>
<script>let nowtimeserver={{ now_time_server }};</script>
<script>let favicon_baseURL="{{ url_for('static_content', group='favicon', filename="PLACEHOLDER")}}";</script>
<script>
// Initialize Feather icons after the page loads
document.addEventListener('DOMContentLoaded', function() {
feather.replace();
});
</script>

<style>
.checking-now .last-checked {
background-image: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.05) 40%, rgba(0,0,0,0.1) 100%);
Expand All @@ -31,8 +26,12 @@
{{ render_nolabel_field(form.edit_and_watch_submit_button, title="Edit first then Watch") }}
</div>
<div id="watch-group-tag">
<i data-feather="tag" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>
{{ render_field(form.tags, value=active_tag.title if active_tag_uuid else '', placeholder="Watch group / tag", class="transparent-field") }}
</div>

{%- include 'edit/llm_prompt.html' -%}

<div id="quick-watch-processor-type">
{{ render_simple_field(form.processor) }}
</div>
Expand Down
44 changes: 44 additions & 0 deletions changedetectionio/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@
default_method = 'GET'
allow_simplehost = not strtobool(os.getenv('BLOCK_SIMPLEHOSTS', 'False'))

LLM_example_texts = ['Tell me simply "Price, In stock"',
'Give me a list of all products for sale in this text',
'Tell me simply "Yes" "No" or "Maybe" if you think the weather outlook is good for a 4-day small camping trip',
'Look at this restaurant menu and only give me list of meals you think are good for type 2 diabetics, if nothing is found just say "nothing"',
]

LLM_send_type_choices = [('text', 'Plain text after filters'),
('above_fold_text', 'Text above the fold'),
('Screenshot', 'Screenshot / Selection'),
('HTML', 'HTML Source')
]

class StringListField(StringField):
widget = widgets.TextArea()

Expand Down Expand Up @@ -515,18 +527,23 @@ def __call__(self, form, field):

class quickWatchForm(Form):
from . import processors
import random

url = fields.URLField('URL', validators=[validateURL()])
tags = StringTagUUID('Group tag', [validators.Optional()])
watch_submit_button = SubmitField('Watch', render_kw={"class": "pure-button pure-button-primary"})
processor = RadioField(u'Processor', choices=processors.available_processors(), default="text_json_diff")
LLM_prompt = TextAreaField(u'AI Prompt', [validators.Optional()], render_kw={"placeholder": f'Example, "{random.choice(LLM_example_texts)}"'})
LLM_send_type = RadioField(u'LLM Send', choices=LLM_send_type_choices, default="text")

edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"})



# Common to a single watch and the global settings
class commonSettingsForm(Form):
from . import processors
import random

def __init__(self, formdata=None, obj=None, prefix="", data=None, meta=None, **kwargs):
super().__init__(formdata, obj, prefix, data, meta, **kwargs)
Expand All @@ -544,6 +561,8 @@ def __init__(self, formdata=None, obj=None, prefix="", data=None, meta=None, **k
timezone = StringField("Timezone for watch schedule", render_kw={"list": "timezones"}, validators=[validateTimeZoneName()])
webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1, message="Should contain one or more seconds")])

LLM_prompt = TextAreaField(u'AI Prompt', [validators.Optional()], render_kw={"placeholder": f'Example, "{random.choice(LLM_example_texts)}"'})
LLM_send_type = RadioField(u'LLM Send', choices=LLM_send_type_choices, default="text")

class importForm(Form):
from . import processors
Expand Down Expand Up @@ -742,6 +761,29 @@ class globalSettingsApplicationUIForm(Form):
socket_io_enabled = BooleanField('Realtime UI Updates Enabled', default=True, validators=[validators.Optional()])
favicons_enabled = BooleanField('Favicons Enabled', default=True, validators=[validators.Optional()])

class globalSettingsApplicationAIKeysForm(Form):

openai = StringField('OpenAI Key',
validators=[validators.Optional()],
render_kw={"placeholder": 'xxxxxxxxx'}
)
gemini = StringField('Google Gemini Key',
validators=[validators.Optional()],
render_kw={"placeholder": 'ooooooooo'}
)

class globalSettingsApplicationAIForm(Form):

#@todo use only configured types?
LLM_backend = RadioField(u'LLM Backend',
choices=[('openai', 'Open AI'), ('gemini', 'Gemini')],
default="text")

# So that we can pass this to our LLM/__init__.py as a keys dict
API_keys = FormField(globalSettingsApplicationAIKeysForm)



# datastore.data['settings']['application']..
class globalSettingsApplicationForm(commonSettingsForm):

Expand Down Expand Up @@ -774,6 +816,8 @@ class globalSettingsApplicationForm(commonSettingsForm):
message="Should contain zero or more attempts")])
ui = FormField(globalSettingsApplicationUIForm)

ai = FormField(globalSettingsApplicationAIForm)


class globalSettingsForm(Form):
# Define these as FormFields/"sub forms", this way it matches the JSON storage
Expand Down
4 changes: 4 additions & 0 deletions changedetectionio/model/App.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ class model(dict):
'socket_io_enabled': True,
'favicons_enabled': True
},
'ai': {
'openai_key': None,
'gemini_key': None
}
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions changedetectionio/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ def __init__(self, *arg, **kw):
'last_error': False,
'last_notification_error': None,
'last_viewed': 0, # history key value of the last viewed via the [diff] link
'LLM_prompt': None,
'LLM_send_type': None,
'LLM_backend': None,
'method': 'GET',
'notification_alert_count': 0,
'notification_body': None,
Expand Down
64 changes: 64 additions & 0 deletions changedetectionio/processors/LLM/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import importlib
from langchain_core.messages import SystemMessage, HumanMessage

SYSTEM_MESSAGE = (
"You are a text analyser who will attempt to give the most concise information "
"to the request, the information should be returned in a way that if I ask you again "
"I should get the same answer if the outcome is the same. The goal is to cut down "
"or reduce the text changes from you when i ask the same question about similar content "
"Always list items in exactly the same order and wording as found in the source text. "
)


class LLM_integrate:
PROVIDER_MAP = {
"openai": ("langchain_openai", "ChatOpenAI"),
"azure": ("langchain_community.chat_models", "AzureChatOpenAI"),
"gemini": ("langchain_google_genai", "ChatGoogleGenerativeAI")
}

def __init__(self, api_keys: dict):
"""
api_keys = {
"openai": "sk-xxx",
"azure": "AZURE_KEY",
"gemini": "GEMINI_KEY"
}
"""
self.api_keys = api_keys

def run(self, provider: str, model: str, message: str):
module_name, class_name = self.PROVIDER_MAP[provider]

# Import the class dynamically
module = importlib.import_module(module_name)
LLMClass = getattr(module, class_name)

# Create the LLM object
llm_kwargs = {}
if provider == "openai":
llm_kwargs = dict(api_key=self.api_keys.get("openai", ''),
model=model,
# https://api.python.langchain.com/en/latest/chat_models/langchain_openai.chat_models.base.ChatOpenAI.html#langchain_openai.chat_models.base.ChatOpenAI.temperature
temperature=0 # most deterministic,
)
elif provider == "azure":
llm_kwargs = dict(
api_key=self.api_keys["azure"],
azure_endpoint="https://<your-endpoint>.openai.azure.com",
deployment_name=model
)
elif provider == "gemini":
llm_kwargs = dict(api_key=self.api_keys.get("gemini"), model=model)

llm = LLMClass(**llm_kwargs)

# Build your messages
messages = [
SystemMessage(content=SYSTEM_MESSAGE),
HumanMessage(content=message)
]

# Run the model asynchronously
result = llm.invoke(messages)
return result.content
1 change: 1 addition & 0 deletions changedetectionio/processors/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from abc import abstractmethod
from changedetectionio.content_fetchers.base import Fetcher
from changedetectionio.processors.LLM import LLM_integrate
from changedetectionio.strtobool import strtobool
from copy import deepcopy
from loguru import logger
Expand Down
26 changes: 25 additions & 1 deletion changedetectionio/processors/text_json_diff/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import urllib3

from changedetectionio.conditions import execute_ruleset_against_all_plugins
from changedetectionio.processors import difference_detection_processor
from changedetectionio.processors import difference_detection_processor, LLM_integrate
from changedetectionio.html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text, TRANSLATE_WHITESPACE_TABLE
from changedetectionio import html_tools, content_fetchers
from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT
Expand Down Expand Up @@ -293,6 +293,30 @@ def run_changedetection(self, watch):
# we end up with 'Some text\n\n', sorting will add all those extra \n at the start, so we remove them here.
stripped_text_from_html = stripped_text_from_html.replace("\n\n", "\n")
stripped_text_from_html = '\n'.join(sorted(stripped_text_from_html.splitlines(), key=lambda x: x.lower()))
### OPENAI?


# And here we run LLM integration based on the content we received
LLM_keys = self.datastore.data['settings']['application']['ai'].get('API_keys', {})
if watch.get('LLM_prompt') and stripped_text_from_html and LLM_keys:
response = ""
try:
integrator = LLM_integrate(api_keys=LLM_keys)
response = integrator.run(
provider="openai",
model="gpt-4.1", #gpt-4-turbo
message=f"{watch.get('LLM_prompt')}\n----------- Content follows-----------\n\n{stripped_text_from_html}"
)
except Exception as e:
logger.critical(f"Error running LLM integration {str(e)} (type etc)")
raise(e)
x = 1
# todo is there something special when tokens are used up etc?
else:
stripped_text_from_html = response
# logger.trace("LLM done")
finally:
logger.debug("LLM request done (type etc)")

### CALCULATE MD5
# If there's text to ignore
Expand Down
Binary file added changedetectionio/static/images/open-ai-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions changedetectionio/static/js/watch-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function request_textpreview_update() {
namespace: 'watchEdit'
}).done(function (data) {
console.debug(data['duration'])
$('#error-text').text(data['duration']);
$('#filters-and-triggers #text-preview-before-inner').text(data['before_filter']);
$('#filters-and-triggers #text-preview-inner')
.text(data['after_filter'])
Expand All @@ -37,9 +38,8 @@ function request_textpreview_update() {
}).fail(function (error) {
if (error.statusText === 'abort') {
console.log('Request was aborted due to a new request being fired.');
} else {
$('#filters-and-triggers #text-preview-inner').text('There was an error communicating with the server.');
}
$('#error-text').text(error.responseJSON['error']);
})
}

Expand Down
2 changes: 2 additions & 0 deletions changedetectionio/static/styles/scss/parts/_edit.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@use "_llm-prompt";

ul#conditions_match_logic {
list-style: none;
input, label, li {
Expand Down
Loading
Loading