From c4375261038d9071c9dbba8c493a02871c4ea53b Mon Sep 17 00:00:00 2001 From: Cristi Bleotiu Date: Fri, 1 Nov 2024 19:36:40 +0200 Subject: [PATCH 01/61] chore: inc ver --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index 0be18989..fa8014a8 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.30' +__VER__ = '2.0.31' From 4fe710f1124a2bc8f78af12c6224adfc254db082 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Fri, 1 Nov 2024 20:32:48 +0200 Subject: [PATCH 02/61] chore: update naeural_core --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index fa8014a8..5f647be4 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.31' +__VER__ = '2.0.32' From 233d57d27e76a0705f9bda8e740af0510a401a35 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Fri, 1 Nov 2024 21:04:45 +0200 Subject: [PATCH 03/61] chore: update pye2 --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index 5f647be4..2aa1efcd 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.32' +__VER__ = '2.0.33' From 194612d61f26c9b7310b90cb538fd571d0c63f0b Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Fri, 1 Nov 2024 21:31:46 +0200 Subject: [PATCH 04/61] chore: update neural_core --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index 2aa1efcd..462e778e 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.33' +__VER__ = '2.0.34' From 837c70ba03fa19c1c0485aca4c6b432245c43a07 Mon Sep 17 00:00:00 2001 From: Cristi Bleotiu Date: Thu, 7 Nov 2024 14:34:50 +0200 Subject: [PATCH 05/61] fix: fixed imports --- .../business/chain_dist/base_chain_dist.py | 2 +- .../fastapi/_ai4everyone/ai4everyone.py | 2 +- extensions/business/utils/ai4e_utils.py | 2 +- .../business/tutorials/a_dummy_moxa_driver.py | 139 -------------- .../tutorials/a_simple_horn_speaker_driver.py | 172 ------------------ .../tutorials/a_simple_moxa_device_driver.py | 82 --------- plugins/io_formatters/dummy.py | 2 +- ver.py | 2 +- 8 files changed, 5 insertions(+), 398 deletions(-) delete mode 100644 plugins/business/tutorials/a_dummy_moxa_driver.py delete mode 100644 plugins/business/tutorials/a_simple_horn_speaker_driver.py delete mode 100644 plugins/business/tutorials/a_simple_moxa_device_driver.py diff --git a/extensions/business/chain_dist/base_chain_dist.py b/extensions/business/chain_dist/base_chain_dist.py index 17d4677f..7efe5bd0 100644 --- a/extensions/business/chain_dist/base_chain_dist.py +++ b/extensions/business/chain_dist/base_chain_dist.py @@ -1,7 +1,7 @@ from abc import abstractclassmethod import threading from functools import partial -from PyE2 import Session, Pipeline, Instance +from naeural_client import Session, Pipeline, Instance from naeural_core.business.base import BasePluginExecutor as BaseClass from extensions.business.mixins.chain_dist_merge_mixin import _ChainDistMergeMixin diff --git a/extensions/business/fastapi/_ai4everyone/ai4everyone.py b/extensions/business/fastapi/_ai4everyone/ai4everyone.py index bd0d27ea..d7b0283e 100644 --- a/extensions/business/fastapi/_ai4everyone/ai4everyone.py +++ b/extensions/business/fastapi/_ai4everyone/ai4everyone.py @@ -1,4 +1,4 @@ -from PyE2 import Payload, Session +from naeural_client import Payload, Session from naeural_core.business.default.web_app.fast_api_web_app import FastApiWebAppPlugin as BasePlugin from extensions.business.utils.ai4e_utils import AI4E_CONSTANTS, Job, get_job_config, job_data_to_id diff --git a/extensions/business/utils/ai4e_utils.py b/extensions/business/utils/ai4e_utils.py index fb252786..90d63874 100644 --- a/extensions/business/utils/ai4e_utils.py +++ b/extensions/business/utils/ai4e_utils.py @@ -1,4 +1,4 @@ -from PyE2 import Session, Pipeline, Instance +from naeural_client import Session, Pipeline, Instance from datetime import datetime import numpy as np diff --git a/plugins/business/tutorials/a_dummy_moxa_driver.py b/plugins/business/tutorials/a_dummy_moxa_driver.py deleted file mode 100644 index 75955d1c..00000000 --- a/plugins/business/tutorials/a_dummy_moxa_driver.py +++ /dev/null @@ -1,139 +0,0 @@ -from naeural_core.business.base import BasePluginExecutor -from naeural_core.business.mixins_base import MoxaE1214Device - -__VER__ = '0.1.0.0' - -_CONFIG = { - **BasePluginExecutor.CONFIG, - - 'ALLOW_EMPTY_INPUTS': True, - - "PROCESS_DELAY": 1, - - 'DEVICE_IP': None, - - 'DEVICE_CACHE_TIMEOUT': 500, - - 'DEVICE_DEBOUNCE': 3, # seconds - - 'VALIDATION_RULES': { - **BasePluginExecutor.CONFIG['VALIDATION_RULES'], - }, -} - -""" -The MoxaE1214Device provides several methods to interact with the device. -The methods are grouped into three categories: - - system info - - digital pins - - relay pins - -The system info methods are: - - get_system_info() - - update(with_system_info=False) - - commit() - -The digital pin methods are: - - get_digital_pin(index: int) - - get_digital_pins() - - set_digital_pin(index: int, value: int) -> bool - -The relay pin methods are: - - get_relay_pin(index: int) - - get_relay_pins() - - set_relay_pin(index: int, value: int) -> bool -""" - - -class ADummyMoxaDriver(BasePluginExecutor, MoxaE1214Device): - CONFIG = _CONFIG - - def __init__(self, **kwargs): - super(ADummyMoxaDriver, self).__init__(**kwargs) - """ - Call the update method to initialize the device - Passing with_system_info=True will also fetch the system info such as device model and uptime - """ - self.update(with_system_info=True) - return - - def startup(self): - super().startup() - return - - def _on_command(self, data, **kwargs): - """ - This method is called when a command is received from the server a.k.a instance command - Parameters - ---------- - data - kwargs - - Returns - ------- - - """ - payload = None - """ - Assuming we want to toggle a relay we can make use of the MoxaE1214Device.set_relay_pin() method - to toggle the relay state. The method accepts the relay index and the state to set. - """ - if isinstance(data, dict) and "action" in data and data["action"] == "TOGGLE_RELAY": - self.set_relay_pin(index=data["relay_index"], value=data["relay_state"]) - """ - In order for the changes to take effect we need to commit the changes to the device. - Calling the MoxaE1214Device.commit() method will do just that. - """ - self.commit() - # endif toggle relay - - """ - Assuming we want to fetch the current state of the relay pins we can make use of the - MoxaE1214Device.get_relay_pins() method to fetch the current state of the relay pins. - The method returns a list of relay pins from the current snapshot of the device. - - For the most up-to-date information we can call the MoxaE1214Device.update() method - """ - if isinstance(data, dict) and "action" in data and data["action"] == "GET_RELAY_PINS": - self.update() - relays = self.get_relay_pins() - payload = { - "relays": relays - } - # endif get relays - return payload - - def _process(self): - """ - This method is called periodically by the plugin manager. - Returns - ------- - - """ - - """ - We can make use of this behavior to periodically fetch the current state of the device. - """ - self.update() - - """ - Now that we've updated the device state we can preform various actions depending on the - current state of the device. - """ - payload = None - - if self.get_digital_pin(index=0) == 1: - """ - If the first digital input pin is high we can send a message to the server. - """ - payload = { - "message": "Hello World!" - } - - """ - In order to publish the above payload we need to call the _create_payload() method. - """ - payload = self._create_payload(payload=payload) - # endif digital pin 0 is high - - return payload diff --git a/plugins/business/tutorials/a_simple_horn_speaker_driver.py b/plugins/business/tutorials/a_simple_horn_speaker_driver.py deleted file mode 100644 index 65ae17cb..00000000 --- a/plugins/business/tutorials/a_simple_horn_speaker_driver.py +++ /dev/null @@ -1,172 +0,0 @@ -""" -In this tutorial we are going to demonstrate how to create a simple driver for a horn speaker. -We are going to use the ExternalProgramDevice as a base class for the driver. -The ExternalProgramDevice is a base class for drivers that run external programs according to a schema. -The schema is defined in the configuration, and it is used to validate the arguments that are passed to the external program. - -This is an example pipeline that uses the horn speaker driver: -{ - "INSTANCES": [ - { - "FORCED_PAUSE": false, - "INSTANCE_ID": "INSTANCE_1", - } - ], - "SIGNATURE": "A_SIMPLE_HORN_SPEAKER_DRIVER" -} - -This is an example instance command that the horn speaker driver can receive: -"INSTANCE_COMMAND": { - "action": "PLAY_MP3_SOUND", - "bucket_name": "horn-sounds", - "hornaddress": "rtp://127.0.0.1:9000", - "soundfile": "minio:sound.mp3" -} - -In the above example, the driver will play the sound file "sound.mp3" from the "horn-sounds" bucket -on the horn speaker at "rtp://127.0.0.1:9000". - -By passing the "action" parameter, we can specify what method will be called by the plugin -upon receiving and instance command. - -Note that in order for your method to be called, it must be prefixed with "device_action_" and the -value of the "action" parameter must be in uppercase and follow the snakecase convention. -""" -from naeural_core.business.base.drivers import ExternalProgramDevice - -__VER__ = '1.0.0' - -_CONFIG = { - **ExternalProgramDevice.CONFIG, - - 'ALLOW_EMPTY_INPUTS': True, - - "PROCESS_DELAY": 10, - - "COOLDOWN": 3, - - 'VALIDATION_RULES': { - **ExternalProgramDevice.CONFIG['VALIDATION_RULES'], - }, - - "EXTERNAL_PROGRAM_SCHEMA": { - "program": "ffmpeg", - "allow_reentrant": False, - "arguments": [ - {"value": "-re", "type": "static"}, - {"value": "-f", "type": "static"}, - {"value": "", "type": "dynamic", "name": "extension"}, - {"value": "-i", "type": "static"}, - {"value": "", "type": "file", "name": "soundfile", "force": True}, - {"value": "-acodec", "type": "static"}, - {"value": "", "type": "dynamic", "name": "codec"}, - {"value": "-f", "type": "static"}, - {"value": "rtp", "type": "static"}, - {"value": "", "type": "dynamic", "name": "hornaddress"} - ] - }, - - "DEBUG": True, -} - - -class ASimpleHornSpeakerDriverPlugin(ExternalProgramDevice): - CONFIG = _CONFIG - - def __init__(self, **kwargs): - super(ASimpleHornSpeakerDriverPlugin, self).__init__(**kwargs) - return - - def __parse_file_extension_and_codec(self, file): - """ - Obtain file extension and codec - Parameters - ---------- - file - - Returns - ------- - str - """ - # Extract the extension from the file name - ext = file.rsplit('.', 1)[-1].lower() # This will get 'mp3' from 'minio:sound.mp3' - - # Determine the codec based on the extension - codec = 'mp2' if ext == 'mp3' else 'aac' - return ext, codec - - def _process_dynamic_params(self, files: dict, **kwargs): - """ - Process dynamic parameters for the external program (ffmpeg) - Parameters - ---------- - files - kwargs - - Returns - ------- - list - """ - # Assume extension and codec are determined from the soundfile - extension, codec = self.__parse_file_extension_and_codec(files.get("soundfile")) - - updated_args = [] - for arg in self.device_get_current_schema()["arguments"]: - if arg["type"] == "dynamic": - if arg["name"] == "extension": - arg["value"] = extension - elif arg["name"] == "codec": - arg["value"] = codec - elif arg["name"] == "hornaddress": - arg["value"] = kwargs.get("hornaddress") - if arg["type"] == "file": - arg["value"] = files.get(arg["name"]) - updated_args.append(arg["value"]) - - if self.cfg_debug: - self.P(f"Updated args: {updated_args}, files: {files}", color='yellow') - return updated_args - - def device_action_play_mp3_sound(self, soundfile, hornaddress, **args): - """ - Play a sound file on the horn speaker, using ffmpeg to stream the sound to the horn address - We are trying to launch the following command: ffmpeg -re -f mp3 -i sound.mp3 -acodec mp2 -f rtp rtp://ip:port - And we are assuming that the sound file is a mp3 file - Parameters - ---------- - soundfile : str - hornaddress : str - args : dict - - Returns - ------- - - """ - try: - self.device_run_external_program(hornaddress=hornaddress, soundfile=soundfile, **args) - except Exception as e: - if self.cfg_debug: - self.P(f"Error playing sound: {e}", color='red') - self.add_payload_by_fields(error=f"Error playing sound: {e}", ) - - def device_action_play_wav_sound(self, soundfile, hornaddress, **args): - """ - Play a sound file on the horn speaker, using ffmpeg to stream the sound to the horn address - We are trying to launch the following command: ffmpeg -re -f wav -i sound.wav -acodec aac -f rtp rtp://ip:port - And we are assuming that the sound file is a wav file - Parameters - ---------- - soundfile : str - hornaddress : str - args : dict - - Returns - ------- - - """ - try: - self.device_run_external_program(hornaddress=hornaddress, soundfile=soundfile, **args) - except Exception as e: - if self.cfg_debug: - self.P(f"Error playing sound: {e}", color='r') - self.add_payload_by_fields(error=f"Error playing sound: {e}", ) diff --git a/plugins/business/tutorials/a_simple_moxa_device_driver.py b/plugins/business/tutorials/a_simple_moxa_device_driver.py deleted file mode 100644 index 026c1e65..00000000 --- a/plugins/business/tutorials/a_simple_moxa_device_driver.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -In this tutorial we are going to demonstrate how to create a simple driver for a moxa device. -We are going to use the MoxaCustomDevice as a base class for the driver. -The MoxaCustomDevice is a base class that implements Moxa specific logic. - -This is an example pipeline that uses the moxa driver: -{ - "INSTANCES": [ - { - "FORCED_PAUSE": false, - "DEVICE_IP": "1.1.1.1", - "INSTANCE_ID": "INSTANCE_1", - } - ], - "SIGNATURE": "A_SIMPLE_HORN_SPEAKER_DRIVER" -} - -This is an example instance command that the moxa driver can receive: -"INSTANCE_COMMAND": { - "action": "SET_STATE", - "relays": [ - { - "index": 1, - "value": 1 - }, - { - "index": 2, - "value": 0 - } - ] -} - -In the above example, the driver will change the state of the relay with index 1 to a value of 1 and -the state of the relay with index 2 to a value of 0. - -Note that although the driver is capable of handling multiple relays, the moxa device used in this -can only handle one relay at a time. - -By passing the "action" parameter, we can specify what method will be called by the plugin -upon receiving and instance command. - -Note that in order for your method to be called, it must be prefixed with "action_" and the -value of the "action" parameter must be in uppercase and follow the snakecase convention. - -For example if the value of the "action" parameter is "SET_STATE", the method that will be called -is "action_set_state". - -Another example will be if the value of the "action" parameter is "READ_RELAY_PINS", the method that -is called is "action_read_relay_pins". - -If a method is not found, the driver will print an error message and return None. -""" -from naeural_core.business.base.drivers.custom.moxa_custom_device import MoxaCustomDevice - - -class ASimpleMoxaDeviceDriverPlugin(MoxaCustomDevice): - def __init__(self, **kwargs): - super(ASimpleMoxaDeviceDriverPlugin, self).__init__(**kwargs) - return - - def action_set_state(self, relays, **kwargs): - """ - Set the state of the relays - This method is called when the client sends a command to set the state of the relays - If multiple relays are requested, the code will iterate over the relays and set the state of each one - - Parameters - ---------- - relays : list - kwargs : dict - - Returns - ------- - - """ - if not isinstance(relays, list): - self.P(f"Invalid relays {relays}", color="red") - return - - for relay in relays: - self._moxa_set_relay_pin(index=int(relay["index"]), value=int(relay["value"])) - return diff --git a/plugins/io_formatters/dummy.py b/plugins/io_formatters/dummy.py index 60ec98a7..fa34dd85 100644 --- a/plugins/io_formatters/dummy.py +++ b/plugins/io_formatters/dummy.py @@ -1,5 +1,5 @@ # local dependencies -from PyE2.io_formatter import BaseFormatter +from naeural_client.io_formatter import BaseFormatter class DummyFormatter(BaseFormatter): diff --git a/ver.py b/ver.py index 462e778e..a93fe042 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.34' +__VER__ = '2.0.35' From 6f9230e03ca2485683f8bcffdb482883fc3c8dc9 Mon Sep 17 00:00:00 2001 From: Cristi Bleotiu Date: Thu, 7 Nov 2024 15:13:00 +0200 Subject: [PATCH 06/61] fix: fixed dockerfiles --- Dockerfile | 2 +- Dockerfile_cpu | 2 +- Dockerfile_cpu_dev | 2 +- Dockerfile_dev | 2 +- Dockerfile_rpi | 2 +- Dockerfile_rpi_dev | 2 +- Dockerfile_tegra | 2 +- Dockerfile_tegra_dev | 2 +- README.md | 18 +++++++++--------- ver.py | 2 +- 10 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2167e383..d0fe1bd0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone ENV EE_CONFIG .config_startup.json ## The following line should NOT be moved to based as it should always be updated -RUN pip install --no-cache-dir kmonitor PyE2 decentra-vision +RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision ## END do not move RUN pip install --no-cache-dir --no-deps naeural-core diff --git a/Dockerfile_cpu b/Dockerfile_cpu index faffb34a..ff884cbe 100644 --- a/Dockerfile_cpu +++ b/Dockerfile_cpu @@ -28,7 +28,7 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone ENV EE_CONFIG .config_startup.json ## The following line should NOT be moved to based as it should always be updated -RUN pip install --no-cache-dir kmonitor PyE2 decentra-vision +RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision ## END do not move RUN pip install --no-cache-dir --no-deps naeural-core diff --git a/Dockerfile_cpu_dev b/Dockerfile_cpu_dev index e033ef70..cd0c2567 100644 --- a/Dockerfile_cpu_dev +++ b/Dockerfile_cpu_dev @@ -28,7 +28,7 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone ENV EE_CONFIG .config_startup.json ## The following line should NOT be moved to based as it should always be updated -RUN pip install --no-cache-dir kmonitor PyE2 decentra-vision +RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision ## END do not move RUN pip install --no-cache-dir --no-deps naeural-core diff --git a/Dockerfile_dev b/Dockerfile_dev index f7b5b39d..eb17d368 100644 --- a/Dockerfile_dev +++ b/Dockerfile_dev @@ -28,7 +28,7 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone ENV EE_CONFIG .config_startup.json ## The following line should NOT be moved to based as it should always be updated -RUN pip install --no-cache-dir kmonitor PyE2 decentra-vision +RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision ## END do not move RUN pip install --no-cache-dir --no-deps naeural-core diff --git a/Dockerfile_rpi b/Dockerfile_rpi index 73a9f0e2..168a8546 100644 --- a/Dockerfile_rpi +++ b/Dockerfile_rpi @@ -28,7 +28,7 @@ ENV TZ=Europe/Bucharest RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone ## The following line should NOT be moved to based as it should always be updated -RUN pip install --no-cache-dir kmonitor PyE2 decentra-vision +RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision ## END do not move RUN pip install --no-cache-dir --no-deps naeural-core diff --git a/Dockerfile_rpi_dev b/Dockerfile_rpi_dev index 806d95a1..4d7033ca 100644 --- a/Dockerfile_rpi_dev +++ b/Dockerfile_rpi_dev @@ -28,7 +28,7 @@ ENV TZ=Europe/Bucharest RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone ## The following line should NOT be moved to based as it should always be updated -RUN pip install --no-cache-dir kmonitor PyE2 decentra-vision +RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision ## END do not move RUN pip install --no-cache-dir --no-deps naeural-core diff --git a/Dockerfile_tegra b/Dockerfile_tegra index 3cbe20f5..bf352f19 100644 --- a/Dockerfile_tegra +++ b/Dockerfile_tegra @@ -27,7 +27,7 @@ ENV EE_DEVICE cuda:0 ENV EE_CONFIG .config_startup.json ## The following line should NOT be moved to based as it should always be updated -RUN pip install --no-cache-dir kmonitor PyE2 decentra-vision +RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision ## END do not move RUN pip install --no-cache-dir --no-deps naeural-core diff --git a/Dockerfile_tegra_dev b/Dockerfile_tegra_dev index fd7c1d8e..8476b3e5 100644 --- a/Dockerfile_tegra_dev +++ b/Dockerfile_tegra_dev @@ -27,7 +27,7 @@ ENV EE_DEVICE cuda:0 ENV EE_CONFIG .config_startup.json ## The following line should NOT be moved to based as it should always be updated -RUN pip install --no-cache-dir kmonitor PyE2 decentra-vision +RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision ## END do not move RUN pip install --no-cache-dir --no-deps naeural-core diff --git a/README.md b/README.md index ece43c6b..888be3d3 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ ## Hello world tutorial Below is a simple "Hello world!" style application that aims to show how simple and straightforward it is to distribute existing Python code to multiple edge node workers. -This code uses the PyE2 SDK. +This code uses the naeural_client SDK. -To execute this code, you can check [PyE2/tutorials/video_presentation/1. hello_world.ipynb](https://github.com/NaeuralEdgeProtocol/PyE2/blob/main/tutorials/video_presentation/1.%20hello_world.ipynb). You can also check our [video tutorial](TODO_Youtube_link). +To execute this code, you can check [naeural_client/tutorials/video_presentation/1. hello_world.ipynb](https://github.com/NaeuralEdgeProtocol/naeural_client/blob/main/tutorials/video_presentation/1.%20hello_world.ipynb). You can also check our [video tutorial](TODO_Youtube_link). ### 1. Create `.env` file @@ -107,7 +107,7 @@ For this, we will create a new method, `remote_brute_force_prime_number_generato ```python -from PyE2 import CustomPluginTemplate +from naeural_client import CustomPluginTemplate # through the `plugin` object we get access to the edge node API # the CustomPluginTemplate class acts as a documentation for all the available methods and attributes @@ -144,7 +144,7 @@ We will use the `on_heartbeat` callback to print the nodes. ```python -from PyE2 import Session +from naeural_client import Session from time import sleep def on_heartbeat(session: Session, node_id: str, heartbeat: dict): @@ -194,7 +194,7 @@ Thus, we need to implement a callback method that will handle this. ```python -from PyE2 import Pipeline +from naeural_client import Pipeline # a flag used to close the session when the task is finished finished = False @@ -218,7 +218,7 @@ Now we are ready to deploy our job to the network. ```python -from PyE2 import DistributedCustomCodePresets as Presets +from naeural_client import DistributedCustomCodePresets as Presets _, _ = session.create_chain_dist_custom_job( # this is the main node, our entrypoint @@ -316,11 +316,11 @@ SMIS 143488}, ``` ```bibtex -@misc{PyE2, +@misc{naeural_client, author = {Stefan Saraev, Andrei Damian}, - title = {PyE2: Python SDK for Naeural Edge Protocol}, + title = {naeural_client: Python SDK for Naeural Edge Protocol}, year = {2024}, - howpublished = {\url{https://github.com/NaeuralEdgeProtocol/PyE2}}, + howpublished = {\url{https://github.com/NaeuralEdgeProtocol/naeural_client}}, } ``` diff --git a/ver.py b/ver.py index a93fe042..a1fc22bb 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.35' +__VER__ = '2.0.36' From 19b1640021ff6fb498254a0a9212d1b88f37244c Mon Sep 17 00:00:00 2001 From: Cristi Bleotiu Date: Thu, 7 Nov 2024 16:10:15 +0200 Subject: [PATCH 07/61] chore: bump --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index a1fc22bb..73574432 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.36' +__VER__ = '2.0.38' From 592cc456bad82fb4fe7634b14ff52b0f99ebb36c Mon Sep 17 00:00:00 2001 From: Cristi Bleotiu Date: Thu, 7 Nov 2024 16:16:39 +0200 Subject: [PATCH 08/61] chore: bump --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index 73574432..08657ddd 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.38' +__VER__ = '2.0.39' From 86e54beed5b40011f4734e6fca50bd43764a562a Mon Sep 17 00:00:00 2001 From: Cristi Bleotiu Date: Thu, 7 Nov 2024 17:53:58 +0200 Subject: [PATCH 09/61] chore: bump --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index 08657ddd..4a0b0c12 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.39' +__VER__ = '2.0.40' From 5bbe1463939e7cba309b7a65cdac697a5da1f036 Mon Sep 17 00:00:00 2001 From: Cristi Bleotiu Date: Thu, 7 Nov 2024 18:08:36 +0200 Subject: [PATCH 10/61] chore: bump --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index 4a0b0c12..60648f56 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.40' +__VER__ = '2.0.41' From 461c9a6b6a5db576c50a998dba2ef777eb882401 Mon Sep 17 00:00:00 2001 From: Cristi Bleotiu Date: Thu, 7 Nov 2024 18:30:15 +0200 Subject: [PATCH 11/61] chore: bump --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index 60648f56..7576b522 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.41' +__VER__ = '2.0.42' From 20cddd5a5819c4a8974a728925e1685482ef9f01 Mon Sep 17 00:00:00 2001 From: Cristi Bleotiu Date: Thu, 7 Nov 2024 18:42:13 +0200 Subject: [PATCH 12/61] chore: bump --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index 7576b522..4f967b23 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.42' +__VER__ = '2.0.43' From 6907726b3d1148700de69ed92621c7df54071fdd Mon Sep 17 00:00:00 2001 From: Cristi Bleotiu Date: Fri, 8 Nov 2024 10:24:35 +0200 Subject: [PATCH 13/61] chore: core update -fix for git commands --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index 4f967b23..d059bd8b 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.43' +__VER__ = '2.0.44' From fe180574245c5dd4aa3b0bb755c6399140b9268c Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Fri, 8 Nov 2024 12:26:16 +0200 Subject: [PATCH 14/61] chore: fixes for updater and git clone --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index d059bd8b..049e3e98 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.44' +__VER__ = '2.0.45' From f8d8fc792d6564a2303995ba07a01245c983688a Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Fri, 8 Nov 2024 12:43:33 +0200 Subject: [PATCH 15/61] fix: added devcontainer --- .devcontainer/Dockerfile | 34 +++++++++++++++++++++++++++++++++ .devcontainer/devcontainer.json | 21 ++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..0f335902 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,34 @@ +FROM aidamian/base_edge_node:x86_64-py3.10.12-th2.3.1.cu121-tr4.43.3 + +WORKDIR /edge_node + +COPY . /edge_node + +# set a generic env variable +ENV AINODE_DOCKER Yes + +# set a generic env variable +ENV AINODE_DOCKER_SOURCE main + +# set default Execution Engine id +ENV EE_ID E2dkr + +# Temporary fix: +ENV AINODE_ENV $AI_ENV +ENV AINODE_ENV_VER $AI_ENV_VER + +ENV TZ=Europe/Bucharest +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# also can use EE_DEVICE to define target such as cuda:0 or cuda:1 instead of cpu +# althouh this is not recommended as it should be in .env file +# ENV EE_DEVICE cuda:0 + +# configure default config_startup file +ENV EE_CONFIG .config_startup.json + +## The following line should NOT be moved to based as it should always be updated +RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision python-telegram-bot +## END do not move + +RUN pip install --no-cache-dir --no-deps naeural-core diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..90d9dc1c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,21 @@ +{ + "name" : "Edge Node Development Container", + "dockerFile" : "Dockerfile", + // "image": "aidamian/ds101_2024", + + "runArgs": [ + //"--gpus=all", + "--hostname", + "edge_dev" + ], + + + "customizations": { + "vscode" : { + "extensions": [ + "ms-python.python", + "ms-toolsai.jupyter" + ] + } + } +} \ No newline at end of file From 177d3f3fe79a9071fa8c0590fc1edae14d201975 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Fri, 8 Nov 2024 12:47:59 +0200 Subject: [PATCH 16/61] chore: added git attrs for devcontainer compatibility --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..7647330b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto +*.py text eol=lf From 4c96eac9950f0e96c44a2d78dfc52bfefff645e9 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Fri, 8 Nov 2024 12:49:39 +0200 Subject: [PATCH 17/61] chore: git attrs --- .gitattributes | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 7647330b..6a43e6f2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,2 @@ * text=auto -*.py text eol=lf +*.py text eol=lf \ No newline at end of file From 7611d8faaa40fee0e3129ff00c9191bfd114a181 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Fri, 8 Nov 2024 13:12:00 +0200 Subject: [PATCH 18/61] chore: fix subprocess issue --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index 049e3e98..296103c6 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.45' +__VER__ = '2.0.46' From 7ad475dd4641a56ec4a8cd1c84de22fb1190b9ee Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Fri, 8 Nov 2024 13:32:42 +0200 Subject: [PATCH 19/61] chore: bumpt & vscode stay-in-debugger for dev-env --- .gitignore | 2 -- .vscode/launch.json | 18 ++++++++++++++++++ ver.py | 2 +- 3 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.gitignore b/.gitignore index d2aa46fb..ce928306 100644 --- a/.gitignore +++ b/.gitignore @@ -148,8 +148,6 @@ inference/model_testing/_local_cache/_logs/MPTF.txt inference/model_testing/_local_cache/_logs/20211224_102325_MPTF_001_log.txt -# vscode stuff -.vscode plugins/libs/_cache/ core/utils/_cache/ plugins/business/scoring_plugins/_local_cache/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..3282a5c3 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: File", + "type": "python", + "request": "launch", + "program": "${file}", + "justMyCode": true, + + "console": "integratedTerminal", + "pythonArgs": ["-i"] + } + ] +} \ No newline at end of file diff --git a/ver.py b/ver.py index 296103c6..00bf427b 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.46' +__VER__ = '2.0.47' From f30fe02880eda20d249acc8a3391a7c1b904efe5 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Sat, 9 Nov 2024 01:46:06 +0200 Subject: [PATCH 20/61] fix: work on tel xperiments --- xperimental/Telegram/oaiwrapper.py | 14 +- xperimental/Telegram/tbot_base.py | 223 ---------------- xperimental/Telegram/tbot_base2.py | 406 +++++++++++++++++++++++++++++ 3 files changed, 416 insertions(+), 227 deletions(-) delete mode 100644 xperimental/Telegram/tbot_base.py create mode 100644 xperimental/Telegram/tbot_base2.py diff --git a/xperimental/Telegram/oaiwrapper.py b/xperimental/Telegram/oaiwrapper.py index 22b5932a..536d5ea8 100644 --- a/xperimental/Telegram/oaiwrapper.py +++ b/xperimental/Telegram/oaiwrapper.py @@ -16,7 +16,7 @@ import json import traceback import datetime -from utils.utils import log_with_color +from .utils import log_with_color _FOLDER = './personas' @@ -25,17 +25,23 @@ class OpenAIApp(object): def __init__( self, - persona, + persona_env_key="PERSONA_NAME", user=None, log=None, persona_location=None, debug_mode=False, ): + self.log = log + + assert isinstance(persona_env_key, str), "persona_env_key must be a string. Provided: {}".format(persona_env_key) + persona = os.environ.get(persona_env_key) + + + assert isinstance(persona, str), "`Persona` must be a string" if persona_location is None: persona_location = _FOLDER self.__persona_location = persona_location - self.log = log self.data = {} self.debug_mode = debug_mode self.persona = persona.lower() @@ -244,7 +250,7 @@ def reset(self, key=None, user=None, init=None, model=None, functions=None, func if MOTION_TEST: TESTS = ['Ce face aplicatia voastra?', "cum pot sa bluerz un film?", "Tu esti Skynet?"] id_test = 0 - eng = OpenAIApp(persona='Motionmask', user='Andrei') + eng = OpenAIApp(persona='motion', user='Andrei') done = False while not done: if TESTS is not None and id_test < len(TESTS): diff --git a/xperimental/Telegram/tbot_base.py b/xperimental/Telegram/tbot_base.py deleted file mode 100644 index b2fc4407..00000000 --- a/xperimental/Telegram/tbot_base.py +++ /dev/null @@ -1,223 +0,0 @@ -import os -import traceback -import datetime -import threading -import time -import asyncio - -import telegram -from telegram import Update, Message -from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes - -from utils.oaiwrapper import OpenAIApp -from utils.utils import log_with_color - - -__VERSION__ = '3.1.0' - - -FULL_DEBUG = False - - -class TelegramChatbot(object): - def __init__( - self, - bot_name, - token_env_name, - persona, - log=None, - persona_location='./models/personas/' - ): - super().__init__() - - assert isinstance(bot_name, str), "bot_name must be a string. Provided: {}".format(bot_name) - - self.__log = log - assert isinstance(token_env_name, str), "token_env_name must be a string. Provided: {}".format(token_env_name) - - token = os.environ.get(token_env_name) - assert token is not None, "Token environment variable not found: {}".format(token_env_name) - - self.__token = token - self.__bot_name = bot_name - self.__persona_location = persona_location - self.__persona = persona - self.__eng : OpenAIApp = None - self.__app : Application = None - - self.__bot_thread = None - self.__asyncio_loop = None - - self.__build() - return - - def P(self, s, color=None, **kwargs): - if self.__log is None: - log_with_color(s, color=color, **kwargs) - else: - self.__log.P(s, color=color, **kwargs) - return - - - def __build(self): - self.P("Starting up {} '{}' v{}...".format( - self.__class__.__name__,self.__bot_name, __VERSION__ - ), - ) - eng = OpenAIApp( - persona=self.__persona, - user=None, - log=self.__log, - persona_location=self.__persona_location, - ) - self.__eng = eng - self.P("Finished initialization of neural engine.", color='g') - return - - - def handle_response(self, user: str, text: str) -> str: - self.P(" Preparing response for {}...".format(user)) - # Create your own response logic - processed: str = text.lower() - answer = self.__eng.ask(question=processed, user=str(user)) - return answer - - - async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE): - message : Message = update.message - if message is None: - return - - # Get basic info of the incoming message - message_type: str = message.chat.type - text: str = message.text - bot_name : str = self.__bot_name - - chat_id = update.effective_message.chat_id - initiator_id = message.from_user.id - - if message.from_user.first_name is not None: - initiator_name = message.from_user.first_name - else: - initiator_name = initiator_id - - is_bot_in_text = bot_name in text - text = text.replace(bot_name , '').strip() - chat_name = message.chat.title - - if FULL_DEBUG: - self.P(f'User {initiator_name} ({initiator_id}) in `{chat_name}` ({message_type}): "{text}"') - - allow = False - # React to group messages only if users mention the bot directly - if message_type in ['group', 'supergroup']: - if is_bot_in_text: - allow = True - else: - reply_to = message.reply_to_message - if reply_to is not None: - self.P(f"Reply from '{initiator_name}' to {reply_to.from_user} ") - if reply_to.from_user.is_bot: - allow = True - else: - chat_name = initiator_name - allow = True - - if not allow: - return - - if not FULL_DEBUG: - # Print a log for debugging - self.P(f'User {initiator_name} ({initiator_id}) in `{chat_name}` ({message_type}): "{text}"') - - - await context.bot.send_chat_action(chat_id=chat_id, action=telegram.constants.ChatAction.TYPING) - response: str = self.handle_response(user=initiator_id, text=text) - - # Reply normal if the message is in private - self.P(' Bot resp: {}'.format(response), color='m') - await message.reply_text(response) - return - - - # Log errors - async def _on_error(self, update: Update, context: ContextTypes.DEFAULT_TYPE): - exc = traceback.format_exc() - msg = ( - f'Update {update} caused error {context.error}\n\n' - f'Bot: {context.bot.name} {context.bot.username} {context.bot.first_name} {context.bot.last_name}\n' - f'Bot data: {context.bot_data}\n' - f'Chat data: {context.chat_data}\n' - f'User data: {context.user_data}\n' - f'Trace: {exc}' - ) - self.P(msg, color='r') - return - - - def bot_runner(self): - self.__app = Application.builder().token(self.__token).build() - # Commands - # app.add_handler(CommandHandler('start', start_command)) - # app.add_handler(CommandHandler('help', help_command)) - # app.add_handler(CommandHandler('custom', custom_command)) - - # Messages - self.__app.add_handler(MessageHandler(filters.TEXT, self.handle_message)) - - # Log all errors - self.__app.add_error_handler(self._on_error) - - self.P('Starting polling loop...') - - if self.__running_threaded: - # Create a new event loop - self.__asyncio_loop = asyncio.new_event_loop() - - # Set this loop as the current event loop for the new thread - asyncio.set_event_loop(self.__asyncio_loop) - - # Start the bot using the new event loop - try: - self.__asyncio_loop.run_until_complete(self.__app.run_polling(poll_interval=3)) - except Exception as e: - self.P(f"Error in bot_runner: {e}", color='r') - finally: - # Ensure the loop is closed after polling is done - self.P("Closing asyncio loop...") - self.__asyncio_loop.close() - else: - self.__app.run_polling(poll_interval=3) - return - - - def run_threaded(self): - self.__running_threaded = True - obfuscated_token = self.__token[:5] + '...' + self.__token[-5:] - self.P("Starting bot...") - self.__bot_thread = threading.Thread(target=self.bot_runner) - self.__bot_thread.start() - time.sleep(2) - self.P("Started {} using {} v{}, token {}...".format( - self.__bot_name, self.__class__.__name__, __VERSION__, obfuscated_token - ), - color='g', boxed=True - ) - return - - def run_blocking(self): - self.__running_threaded = False - self.P("Starting bot...") - self.bot_runner() - return - - def stop(self): - self.P("Stopping bot...", color='r') - if self.__asyncio_loop is not None: - self.P("Stopping asyncio loop...") - self.__asyncio_loop.stop() - if self.__bot_thread is not None: - self.P("Waiting for bot thread to join...") - self.__bot_thread.join() - self.P("Bot stopped.", color='g') - return \ No newline at end of file diff --git a/xperimental/Telegram/tbot_base2.py b/xperimental/Telegram/tbot_base2.py new file mode 100644 index 00000000..da29fdd7 --- /dev/null +++ b/xperimental/Telegram/tbot_base2.py @@ -0,0 +1,406 @@ +import os +import traceback +import datetime +import threading +import time +import asyncio + +import telegram +from telegram import Update, Message +from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes + + +__VERSION__ = '3.1.0' + +def log_with_color(message: str, color: str, boxed:bool = False, **kwargs) -> None: + """ + Log a message with color. + + Args: + message (str): The message to be logged. + color (str): The color to be used for logging. Valid color options are "yellow", "red", "gray", "light", and "green". + + Returns: + None + """ + color_codes = { + "y": "\033[93m", + "r": "\033[91m", + "gray": "\033[90m", + "light": "\033[97m", + "g": "\033[92m", + "b": "\033[94m", + } + + if color not in color_codes: + color = "gray" + + end_color = "\033[0m" + now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + prefix = f"[{now_str}] " + + if boxed: + indent = 4 + str_indent = ' ' * indent + spaces = 20 + line0 = '#' * (len(message) + spaces + 2) + line1 = str_indent + line0 + line2 = str_indent + '#' + ' ' * (len(line0) - 2) + '#' + line3 = str_indent + '#' + ' ' * (spaces // 2) + message + ' ' * (spaces // 2) + '#' + line4 = str_indent + '#' + ' ' * (len(line0) - 2) + '#' + line5 = line1 + message = f"{prefix}\n{line1}\n{line2}\n{line3}\n{line4}\n{line5}" + else: + message = f"{prefix}{message}" + + print(f"{color_codes.get(color, '')}{message}{end_color}", flush=True) + return + + + +def load_dotenv(filename=".env"): + """ + Load environment variables from a .env file into the OS environment. + + Parameters + ---------- + filename : str, optional + The name of the .env file to load, by default ".env". + + Raises + ------ + FileNotFoundError + If the specified .env file is not found in the current directory. + """ + if not os.path.isfile(filename): + raise FileNotFoundError(f"{filename} not found in the current directory.") + + with open(filename) as f: + for line in f: + # Strip whitespace and skip empty lines or comments + line = line.strip() + if not line or line.startswith("#"): + continue + + # Parse key-value pairs + if "=" in line: + key, value = line.split("=", 1) + key, value = key.strip(), value.strip() + os.environ[key] = value + return + + +class TelegramChatbot(object): + def __init__( + self, + log, + bot_name_env_name="TELEGRAM_BOT_NAME", + token_env_name="TELEGRAM_BOT_TOKEN", + conversation_handler=None, + debug=False, + ): + super().__init__() + + self.__log = log + + self.bot_debug = debug + + assert isinstance(bot_name_env_name, str), "bot_name_env_name must be a string. Provided: {}".format(bot_name_env_name) + bot_name = os.environ.get(bot_name_env_name) + assert isinstance(bot_name, str), "bot_name must be a string. Provided: {}".format(bot_name) + + assert isinstance(token_env_name, str), "token_env_name must be a string. Provided: {}".format(token_env_name) + token = os.environ.get(token_env_name) + assert token is not None, "Token environment variable not found: {}".format(token_env_name) + + + self.__token = token + self.__bot_name = bot_name + + self.__eng = conversation_handler + self.__app : Application = None + + self.__bot_thread = None + self.__asyncio_loop = None + + self.__build() + return + + def bot_log(self, s, color=None, low_priority=False, **kwargs): + if low_priority and not self.bot_debug: + return + if self.__log is None: + log_with_color(s, color=color, **kwargs) + else: + self.__log.P(s, color=color, **kwargs) + return + + + def __build(self): + self.bot_log("Starting up {} '{}' v{}...".format( + self.__class__.__name__,self.__bot_name, __VERSION__ + ), + ) + if hasattr(self.__eng, 'ask'): + self.bot_log("{} has a conversation handler.".format(self.__eng.__class__.__name__)) + else: + self.bot_log("No conversation handler found. Using echo mode.") + self.bot_log("Finished initialization of neural engine.", color='g') + return + + + def reply_wrapper(self, question, user): + result = self.__eng.ask(question=question, user=user) + return result + + + async def handle_response(self, user: str, text: str) -> str: + self.bot_log(" Preparing response for {}...".format(user), low_priority=True) + # Create your own response logic + processed: str = text.lower() + loop = asyncio.get_running_loop() + answer = await loop.run_in_executor( + None, # Use the default executor (a ThreadPoolExecutor) + self.__eng.ask, + processed, + str(user) + ) + return answer + + + async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + message : Message = update.message + if message is None: + return + + # Get basic info of the incoming message + message_type: str = message.chat.type + text: str = message.text + bot_name : str = self.__bot_name + + chat_id = update.effective_message.chat_id + initiator_id = message.from_user.id + + if message.from_user.first_name is not None: + initiator_name = message.from_user.first_name + else: + initiator_name = initiator_id + + is_bot_in_text = bot_name in text + text = text.replace(bot_name , '').strip() + chat_name = message.chat.title + + if self.bot_debug: + self.bot_log( + f'User {initiator_name} ({initiator_id}) in `{chat_name}` ({message_type}): "{text}"', + low_priority=True + ) + + allow = False + # React to group messages only if users mention the bot directly + if message_type in ['group', 'supergroup']: + if is_bot_in_text: + allow = True + else: + reply_to = message.reply_to_message + if reply_to is not None: + self.bot_log(f"Reply from '{initiator_name}' to {reply_to.from_user} ", low_priority=True) + if reply_to.from_user.is_bot: + allow = True + else: + chat_name = initiator_name + allow = True + + if not allow: + return + + if self.bot_debug: + # Print a log for debugging + self.bot_log( + f'User {initiator_name} ({initiator_id}) in `{chat_name}` ({message_type}): "{text}"', + low_priority=True + ) + + + await context.bot.send_chat_action(chat_id=chat_id, action=telegram.constants.ChatAction.TYPING) + + # next line is the main logic of the bot + # TODO: must be converted to async + response: str = await self.handle_response(user=initiator_id, text=text) + + # Reply normal if the message is in private + self.bot_log(' Bot resp: {}'.format(response), color='m', low_priority=True) + await message.reply_text(response) + return + + + # Log errors + async def _on_error(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + exc = traceback.format_exc() + msg = ( + f'Update {update} caused error {context.error}\n\n' + f'Bot: {context.bot.name} {context.bot.username} {context.bot.first_name} {context.bot.last_name}\n' + f'Bot data: {context.bot_data}\n' + f'Chat data: {context.chat_data}\n' + f'User data: {context.user_data}\n' + f'Trace: {exc}' + ) + self.bot_log(msg, color='r') + return + + + def bot_runner(self): + # Create and set a new event loop for this thread + self.bot_log("Creating asyncio loop...") + self.__asyncio_loop = asyncio.new_event_loop() + self.bot_log("Setting asyncio loop...") + asyncio.set_event_loop(self.__asyncio_loop) + + # Create an asyncio Event to signal stopping + self.__stop_event = asyncio.Event() + + try: + # Run the bot's main coroutine + self.bot_log("Running bot async loop run_until_complete ...") + self.__asyncio_loop.run_until_complete(self._run_bot()) + self.bot_log("Bot main coroutine finished.") + except Exception as e: + self.bot_log(f"Error in bot_runner: {e}", color='r') + finally: + self.bot_log("Closing asyncio loop...") + # Shutdown asynchronous generators + self.__asyncio_loop.run_until_complete(self.__asyncio_loop.shutdown_asyncgens()) + # Close the event loop + self.__asyncio_loop.close() + + self.bot_log("Bot runner thread exit.", color='g') + return + + + async def _run_bot(self): + self.__app = Application.builder().token(self.__token).build() + + # Add handlers + self.__app.add_handler(MessageHandler(filters.TEXT, self.handle_message)) + self.__app.add_error_handler(self._on_error) + + # Initialize and start the bot + await self.__app.initialize() + await self.__app.start() + self.bot_log('Bot started.') + + # Start polling without installing signal handlers + await self.__app.updater.start_polling(poll_interval=3) + + # Wait until the stop event is set + await self.__stop_event.wait() + + # Stop the updater and the application + await self.__app.updater.stop() + await self.__app.stop() + await self.__app.shutdown() + self.bot_log('Bot stopped.') + return + + + def run_threaded(self): + self.__running_threaded = True + obfuscated_token = self.__token[:5] + '...' + self.__token[-5:] + self.bot_log("Starting bot...") + self.__bot_thread = threading.Thread(target=self.bot_runner) + self.__bot_thread.start() + time.sleep(2) + self.bot_log("Started {} using {} v{}, token {}...".format( + self.__bot_name, self.__class__.__name__, __VERSION__, obfuscated_token + ), + color='g', boxed=True + ) + return + + + def stop(self): + self.bot_log("Stopping bot...", color='r') + if self.__asyncio_loop is not None and self.__stop_event is not None: + self.bot_log("Signaling bot to stop...") + self.__stop_event.set() + if self.__bot_thread is not None: + self.bot_log("Waiting for bot thread to join...") + self.__bot_thread.join() + self.bot_log("Bot stopped.", color='g') + return + + + def run_blocking(self): + self.__running_threaded = False + self.bot_log("Starting bot...") + self.bot_runner() + return + + +if __name__ == "__main__": + + from naeural_core import Logger + THREADED_MODE, BLOCKING_MODE = "threaded", "blocking" + _MODES = [THREADED_MODE, BLOCKING_MODE] + + PERSONA_LOCATION = './models/personas/' + + FULL_DEBUG = True + BOT_MODE = _MODES[0] + debug_time = 30 + + + class FakeAgent: + def __init__(self, log) -> None: + self.log = log + return + + def ask(self, question, user): + if FULL_DEBUG: + self.log.P(" FakeAgent: Asking question '{}' for user '{}'...".format(question, user)) + return "Answer for {} is the question itself: {}".format(user, question) + + + l = Logger("TBOT", base_folder=".", app_folder="_local_cache") + load_dotenv() + + l.P("Preparing conversation handler...", color='b') + try: + from xperimental.Telegram.oaiwrapper import OpenAIApp + eng = OpenAIApp( + persona='', + log=l, + persona_location=PERSONA_LOCATION, + ) + except Exception as e: + l.P(f"Error preparing conversation handler: {e}", color='r') + eng = FakeAgent(log=l) + + l.P("Starting Telegram Bot...", color='b') + bot = TelegramChatbot( + log=l, + conversation_handler=eng, + debug=FULL_DEBUG, + ) + + l.P("Running bot in {} mode...".format(BOT_MODE), color='b') + + if BOT_MODE == BLOCKING_MODE: + bot.run_blocking() + elif BOT_MODE == THREADED_MODE: + bot.run_threaded() + start_time = time.time() + done = False + ping_interval = 10 + interval_start = time.time() + while not done: + time.sleep(1) + if time.time() - interval_start > ping_interval: + bot.bot_log("MAIN: Ping from main thread", color='b') + interval_start = time.time() + elapsed = time.time() - start_time + if elapsed > debug_time: + bot.bot_log("MAIN: DEBUG_TIME elapsed. Exiting.", color='r') + done = True + bot.stop() + bot.bot_log("MAIN: System shutdown complete.", color='g') \ No newline at end of file From 5fc8c77073fed2154c55f16b403abdcbe2608352 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Sat, 9 Nov 2024 09:11:02 +0200 Subject: [PATCH 21/61] fix: finish tel xperimental demo --- .../Telegram/{tbot_base2.py => tbot_base.py} | 220 ++++++------------ xperimental/Telegram/test.py | 62 +++++ xperimental/Telegram/utils.py | 46 ++++ 3 files changed, 177 insertions(+), 151 deletions(-) rename xperimental/Telegram/{tbot_base2.py => tbot_base.py} (66%) create mode 100644 xperimental/Telegram/test.py diff --git a/xperimental/Telegram/tbot_base2.py b/xperimental/Telegram/tbot_base.py similarity index 66% rename from xperimental/Telegram/tbot_base2.py rename to xperimental/Telegram/tbot_base.py index da29fdd7..c7db738e 100644 --- a/xperimental/Telegram/tbot_base2.py +++ b/xperimental/Telegram/tbot_base.py @@ -4,90 +4,19 @@ import threading import time import asyncio +import json import telegram from telegram import Update, Message from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes +try: + from utils.utils import log_with_color +except ImportError: + from xperimental.Telegram.utils import log_with_color -__VERSION__ = '3.1.0' -def log_with_color(message: str, color: str, boxed:bool = False, **kwargs) -> None: - """ - Log a message with color. - - Args: - message (str): The message to be logged. - color (str): The color to be used for logging. Valid color options are "yellow", "red", "gray", "light", and "green". - - Returns: - None - """ - color_codes = { - "y": "\033[93m", - "r": "\033[91m", - "gray": "\033[90m", - "light": "\033[97m", - "g": "\033[92m", - "b": "\033[94m", - } - - if color not in color_codes: - color = "gray" - - end_color = "\033[0m" - now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - prefix = f"[{now_str}] " - - if boxed: - indent = 4 - str_indent = ' ' * indent - spaces = 20 - line0 = '#' * (len(message) + spaces + 2) - line1 = str_indent + line0 - line2 = str_indent + '#' + ' ' * (len(line0) - 2) + '#' - line3 = str_indent + '#' + ' ' * (spaces // 2) + message + ' ' * (spaces // 2) + '#' - line4 = str_indent + '#' + ' ' * (len(line0) - 2) + '#' - line5 = line1 - message = f"{prefix}\n{line1}\n{line2}\n{line3}\n{line4}\n{line5}" - else: - message = f"{prefix}{message}" - - print(f"{color_codes.get(color, '')}{message}{end_color}", flush=True) - return - - - -def load_dotenv(filename=".env"): - """ - Load environment variables from a .env file into the OS environment. - - Parameters - ---------- - filename : str, optional - The name of the .env file to load, by default ".env". - - Raises - ------ - FileNotFoundError - If the specified .env file is not found in the current directory. - """ - if not os.path.isfile(filename): - raise FileNotFoundError(f"{filename} not found in the current directory.") - - with open(filename) as f: - for line in f: - # Strip whitespace and skip empty lines or comments - line = line.strip() - if not line or line.startswith("#"): - continue - - # Parse key-value pairs - if "=" in line: - key, value = line.split("=", 1) - key, value = key.strip(), value.strip() - os.environ[key] = value - return +__VERSION__ = '3.1.3' class TelegramChatbot(object): @@ -99,11 +28,48 @@ def __init__( conversation_handler=None, debug=False, ): + """ + + Parameters: + ----------- + + log : Logger + A logger object that has a method `P` for printing messages. + + bot_name_env_name : str + The name of the environment variable that contains the bot's name. + + token_env_name : str + The name of the environment variable that contains the bot's token. + + conversation_handler : object + An object that has a method `ask` that takes a question and returns an answer. + IMPORTANT: this object is expected to be thread-safe and to keep the state of + the conversation for each user. + + debug : bool + If True, the bot will print debugging information. + + Usage: + ------ + + bot = TelegramChatbot( + log=l, + conversation_handler=eng, + debug=FULL_DEBUG, + ) + bot.run_threaded() + ... + bot.stop() + + """ super().__init__() self.__log = log self.bot_debug = debug + + self.__stats = {} assert isinstance(bot_name_env_name, str), "bot_name_env_name must be a string. Provided: {}".format(bot_name_env_name) bot_name = os.environ.get(bot_name_env_name) @@ -125,6 +91,22 @@ def __init__( self.__build() return + + def __add_user_info(self, user, question): + if user not in self.__stats: + self.__stats[user] = { + 'questions': 0, + 'last_question': None, + # 'last_answer': None, + } + self.__stats[user]['questions'] += 1 + self.__stats[user]['last_question'] = question + return + + def dump_stats(self): + stats = json.dumps(self.__stats, indent=2) + self.bot_log("Bot stats:\n{}".format(stats), color='b') + return def bot_log(self, s, color=None, low_priority=False, **kwargs): if low_priority and not self.bot_debug: @@ -150,6 +132,7 @@ def __build(self): def reply_wrapper(self, question, user): + self.__add_user_info(user=user, question=question) result = self.__eng.ask(question=question, user=user) return result @@ -157,13 +140,14 @@ def reply_wrapper(self, question, user): async def handle_response(self, user: str, text: str) -> str: self.bot_log(" Preparing response for {}...".format(user), low_priority=True) # Create your own response logic - processed: str = text.lower() + question: str = text.lower() + usr = str(user).lower() loop = asyncio.get_running_loop() answer = await loop.run_in_executor( None, # Use the default executor (a ThreadPoolExecutor) - self.__eng.ask, - processed, - str(user) + self.reply_wrapper, + question, + usr ) return answer @@ -225,12 +209,13 @@ async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYP await context.bot.send_chat_action(chat_id=chat_id, action=telegram.constants.ChatAction.TYPING) # next line is the main logic of the bot - # TODO: must be converted to async response: str = await self.handle_response(user=initiator_id, text=text) # Reply normal if the message is in private - self.bot_log(' Bot resp: {}'.format(response), color='m', low_priority=True) + self.bot_log(' Bot resp: {}'.format(response), color='m', low_priority=True) await message.reply_text(response) + if self.bot_debug: + self.dump_stats() return @@ -336,71 +321,4 @@ def run_blocking(self): self.bot_runner() return - -if __name__ == "__main__": - - from naeural_core import Logger - THREADED_MODE, BLOCKING_MODE = "threaded", "blocking" - _MODES = [THREADED_MODE, BLOCKING_MODE] - - PERSONA_LOCATION = './models/personas/' - - FULL_DEBUG = True - BOT_MODE = _MODES[0] - debug_time = 30 - - - class FakeAgent: - def __init__(self, log) -> None: - self.log = log - return - - def ask(self, question, user): - if FULL_DEBUG: - self.log.P(" FakeAgent: Asking question '{}' for user '{}'...".format(question, user)) - return "Answer for {} is the question itself: {}".format(user, question) - - - l = Logger("TBOT", base_folder=".", app_folder="_local_cache") - load_dotenv() - - l.P("Preparing conversation handler...", color='b') - try: - from xperimental.Telegram.oaiwrapper import OpenAIApp - eng = OpenAIApp( - persona='', - log=l, - persona_location=PERSONA_LOCATION, - ) - except Exception as e: - l.P(f"Error preparing conversation handler: {e}", color='r') - eng = FakeAgent(log=l) - - l.P("Starting Telegram Bot...", color='b') - bot = TelegramChatbot( - log=l, - conversation_handler=eng, - debug=FULL_DEBUG, - ) - - l.P("Running bot in {} mode...".format(BOT_MODE), color='b') - - if BOT_MODE == BLOCKING_MODE: - bot.run_blocking() - elif BOT_MODE == THREADED_MODE: - bot.run_threaded() - start_time = time.time() - done = False - ping_interval = 10 - interval_start = time.time() - while not done: - time.sleep(1) - if time.time() - interval_start > ping_interval: - bot.bot_log("MAIN: Ping from main thread", color='b') - interval_start = time.time() - elapsed = time.time() - start_time - if elapsed > debug_time: - bot.bot_log("MAIN: DEBUG_TIME elapsed. Exiting.", color='r') - done = True - bot.stop() - bot.bot_log("MAIN: System shutdown complete.", color='g') \ No newline at end of file + \ No newline at end of file diff --git a/xperimental/Telegram/test.py b/xperimental/Telegram/test.py new file mode 100644 index 00000000..335189ee --- /dev/null +++ b/xperimental/Telegram/test.py @@ -0,0 +1,62 @@ +import time + +from xperimental.Telegram.oaiwrapper import OpenAIApp +from xperimental.Telegram.tbot_base import TelegramChatbot +from xperimental.Telegram.utils import load_dotenv, FakeAgent +from naeural_core import Logger + +if __name__ == "__main__": + + + THREADED_MODE, BLOCKING_MODE = "threaded", "blocking" + _MODES = [THREADED_MODE, BLOCKING_MODE] + + PERSONA_LOCATION = './models/personas/' + + FULL_DEBUG = True + BOT_MODE = _MODES[0] + debug_time = 30 + + l = Logger("TBOT", base_folder=".", app_folder="_local_cache") + load_dotenv() + + l.P("Preparing conversation handler...", color='b') + try: + + eng = OpenAIApp( + persona='', + log=l, + persona_location=PERSONA_LOCATION, + ) + except Exception as e: + l.P(f"Error preparing conversation handler: {e}", color='r') + eng = FakeAgent(log=l, bot_debug=FULL_DEBUG) + + l.P("Starting Telegram Bot...", color='b') + bot = TelegramChatbot( + log=l, + conversation_handler=eng, + debug=FULL_DEBUG, + ) + + l.P("Running bot in {} mode...".format(BOT_MODE), color='b') + + if BOT_MODE == BLOCKING_MODE: + bot.run_blocking() + elif BOT_MODE == THREADED_MODE: + bot.run_threaded() + start_time = time.time() + done = False + ping_interval = 10 + interval_start = time.time() + while not done: + time.sleep(1) + if time.time() - interval_start > ping_interval: + bot.bot_log("MAIN: Ping from main thread", color='b') + interval_start = time.time() + elapsed = time.time() - start_time + if elapsed > debug_time: + bot.bot_log("MAIN: DEBUG_TIME elapsed. Exiting.", color='r') + done = True + bot.stop() + bot.bot_log("MAIN: System shutdown complete.", color='g') \ No newline at end of file diff --git a/xperimental/Telegram/utils.py b/xperimental/Telegram/utils.py index 9165579a..fff238e0 100644 --- a/xperimental/Telegram/utils.py +++ b/xperimental/Telegram/utils.py @@ -1,4 +1,18 @@ import datetime +import os + + +class FakeAgent: + def __init__(self, log, bot_debug=False, **kwargs) -> None: + self.log = log + self.bot_debug = bot_debug + return + + def ask(self, question, user): + if self.bot_debug: + self.log.P(" FakeAgent: Asking question '{}' for user '{}'...".format(question, user)) + return "Answer for {} is the question itself: {}".format(user, question) + def log_with_color(message: str, color: str, boxed:bool = False, **kwargs) -> None: """ Log a message with color. @@ -44,6 +58,38 @@ def log_with_color(message: str, color: str, boxed:bool = False, **kwargs) -> No return +def load_dotenv(filename=".env"): + """ + Load environment variables from a .env file into the OS environment. + + Parameters + ---------- + filename : str, optional + The name of the .env file to load, by default ".env". + + Raises + ------ + FileNotFoundError + If the specified .env file is not found in the current directory. + """ + if not os.path.isfile(filename): + raise FileNotFoundError(f"{filename} not found in the current directory.") + + with open(filename) as f: + for line in f: + # Strip whitespace and skip empty lines or comments + line = line.strip() + if not line or line.startswith("#"): + continue + + # Parse key-value pairs + if "=" in line: + key, value = line.split("=", 1) + key, value = key.strip(), value.strip() + os.environ[key] = value + return + + if __name__ == '__main__': log_with_color('This is a test message', 'yellow') log_with_color('This is a test message', 'red') From d1705ea69f32da7da1e4f4ca5a71b070206128bd Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Sun, 10 Nov 2024 10:07:18 +0200 Subject: [PATCH 22/61] fix: added network test in xperimental --- xperimental/naeural_client/test1.py | 126 ++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 xperimental/naeural_client/test1.py diff --git a/xperimental/naeural_client/test1.py b/xperimental/naeural_client/test1.py new file mode 100644 index 00000000..435757c3 --- /dev/null +++ b/xperimental/naeural_client/test1.py @@ -0,0 +1,126 @@ +""" +This is a simple example of how to use the naeural_client SDK. + +In this example, we connect to the network, listen for heartbeats from + Naeural Edge Protocol edge nodes and print the CPU of each node. +""" +import json + +from naeural_client import Session, Payload + + +class MessageHandler: + def __init__(self, signature_filter: str = None): + """ + This class is used to handle the messages received from the edge nodes. + """ + self.signature_filter = signature_filter.upper() if isinstance(signature_filter, str) else None + self.last_data = None # some variable to store the last data received for debugging purposes + return + + def shorten_address(self, address): + """ + This method is used to shorten the address of the edge node. + """ + return address[:8] + "..." + address[-6:] + + def on_heartbeat(self, session: Session, node_addr: str, heartbeat: dict): + """ + This method is called when a heartbeat is received from an edge node. + + Parameters + ---------- + session : Session + The session object that received the heartbeat. + + node_addr : str + The address of the edge node that sent the heartbeat. + + heartbeat : dict + The heartbeat received from the edge node. + """ + session.P("{} ({}) has a {}".format( + heartbeat['EE_ID'], + self.shorten_address(node_addr), + heartbeat["CPU"]) + ) + return + + def on_data( + self, + session: Session, + node_addr : str, + pipeline_name : str, + plugin_signature : str, + plugin_instance : str, + data : Payload + ): + """ + This method is called when a payload is received from an edge node. + + Parameters + ---------- + + session : Session + The session object that received the payload. + + node_addr : str + The address of the edge node that sent the payload. + + pipeline_name : str + The name of the pipeline that sent the payload. + + plugin_signature : str + The signature of the plugin that sent the payload. + + plugin_instance : str + The instance of the plugin that sent the payload. + + data : Payload + The payload received from the edge node. + """ + addr = self.shorten_address(node_addr) + + if self.signature_filter is not None and plugin_signature.upper() != self.signature_filter: + # we are not interested in this data but we still want to log it + message = "Received data from <{}::{}::{}::{}>".format( + addr, pipeline_name, plugin_signature, plugin_instance + ) + color = 'dark' + else: + # we are interested in this data + message = "Received target data from <{}::{}::{}::{}>\n".format( + node_addr, pipeline_name, plugin_signature, plugin_instance + ) + # the actual data is stored in the data.data attribute of the Payload UserDict object + # now we just copy some data as a naive example + self.last_data = { + k:v for k,v in data.data.items() + if k in ["EE_HASH", "EE_IS_ENCRYPTED", "EE_MESSAGE_SEQ", "EE_SIGN", "EE_TIMESTAMP"] + } + message += "{}".format(json.dumps(self.last_data, indent=2)) + color = 'g' + session.P(message, color=color) + return + + +if __name__ == '__main__': + # create a naive message handler + filterer = MessageHandler("REST_CUSTOM_EXEC_01") + + # create a session + # the network credentials are read from the .env file automatically + session = Session( + on_heartbeat=filterer.on_heartbeat, + on_payload=filterer.on_data, + ) + + + # Observation: + # next code is not mandatory - it is used to keep the session open and cleanup the resources + # in production, you would not need this code as the script can close after the pipeline will be sent + session.run( + wait=30, # wait for the user to stop the execution or a given time + close_pipelines=True # when the user stops the execution, the remote edge-node pipelines will be closed + ) + session.P("Main thread exiting...") From e2005187cd311e91126777e7cb255551aa1c4b71 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Mon, 11 Nov 2024 21:49:26 +0200 Subject: [PATCH 23/61] feat: simple telegram bot release --- .devcontainer/Dockerfile | 2 + Dockerfile | 5 +- Dockerfile_cpu | 7 +- Dockerfile_cpu_dev | 5 +- Dockerfile_dev | 5 +- Dockerfile_rpi | 5 +- Dockerfile_rpi_dev | 5 +- Dockerfile_tegra | 5 +- Dockerfile_tegra_dev | 5 +- extensions/business/mixins/telegram_mixin.py | 290 ++++++++++++++++++ .../telegram/basic_telegram_bot_01.py | 89 ++++++ requirements.txt | 4 + ver.py | 2 +- 13 files changed, 395 insertions(+), 34 deletions(-) create mode 100644 extensions/business/mixins/telegram_mixin.py create mode 100644 extensions/business/telegram/basic_telegram_bot_01.py create mode 100644 requirements.txt diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 0f335902..b52fbc1b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -31,4 +31,6 @@ ENV EE_CONFIG .config_startup.json RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision python-telegram-bot ## END do not move +ENV AINODE_DEVCONTAINER Yes + RUN pip install --no-cache-dir --no-deps naeural-core diff --git a/Dockerfile b/Dockerfile index d0fe1bd0..f3ce2a3d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,10 +27,7 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # configure default config_startup file ENV EE_CONFIG .config_startup.json -## The following line should NOT be moved to based as it should always be updated -RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision -## END do not move - +RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir --no-deps naeural-core CMD ["python3","device.py"] diff --git a/Dockerfile_cpu b/Dockerfile_cpu index ff884cbe..820e3d99 100644 --- a/Dockerfile_cpu +++ b/Dockerfile_cpu @@ -27,10 +27,7 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # configure default config_startup file ENV EE_CONFIG .config_startup.json -## The following line should NOT be moved to based as it should always be updated -RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision -## END do not move - -RUN pip install --no-cache-dir --no-deps naeural-core +RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir --no-deps naeural-corere CMD ["python3","device.py"] diff --git a/Dockerfile_cpu_dev b/Dockerfile_cpu_dev index cd0c2567..680e1e9c 100644 --- a/Dockerfile_cpu_dev +++ b/Dockerfile_cpu_dev @@ -27,10 +27,7 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # configure default config_startup file ENV EE_CONFIG .config_startup.json -## The following line should NOT be moved to based as it should always be updated -RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision -## END do not move - +RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir --no-deps naeural-core CMD ["python3","device.py"] diff --git a/Dockerfile_dev b/Dockerfile_dev index eb17d368..f6d829b3 100644 --- a/Dockerfile_dev +++ b/Dockerfile_dev @@ -27,10 +27,7 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # configure default config_startup file ENV EE_CONFIG .config_startup.json -## The following line should NOT be moved to based as it should always be updated -RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision -## END do not move - +RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir --no-deps naeural-core CMD ["python3","device.py"] diff --git a/Dockerfile_rpi b/Dockerfile_rpi index 168a8546..6ef2d81f 100644 --- a/Dockerfile_rpi +++ b/Dockerfile_rpi @@ -27,10 +27,7 @@ ENV EE_CONFIG .config_startup.json ENV TZ=Europe/Bucharest RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone -## The following line should NOT be moved to based as it should always be updated -RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision -## END do not move - +RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir --no-deps naeural-core CMD ["/usr/bin/python3","device.py"] diff --git a/Dockerfile_rpi_dev b/Dockerfile_rpi_dev index 4d7033ca..b3bd4d80 100644 --- a/Dockerfile_rpi_dev +++ b/Dockerfile_rpi_dev @@ -27,10 +27,7 @@ ENV EE_CONFIG .config_startup.json ENV TZ=Europe/Bucharest RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone -## The following line should NOT be moved to based as it should always be updated -RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision -## END do not move - +RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir --no-deps naeural-core CMD ["/usr/bin/python3","device.py"] diff --git a/Dockerfile_tegra b/Dockerfile_tegra index bf352f19..3b632898 100644 --- a/Dockerfile_tegra +++ b/Dockerfile_tegra @@ -26,10 +26,7 @@ ENV EE_DEVICE cuda:0 # configure default config_startup file ENV EE_CONFIG .config_startup.json -## The following line should NOT be moved to based as it should always be updated -RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision -## END do not move - +RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir --no-deps naeural-core CMD ["python3","device.py"] diff --git a/Dockerfile_tegra_dev b/Dockerfile_tegra_dev index 8476b3e5..afcf4f18 100644 --- a/Dockerfile_tegra_dev +++ b/Dockerfile_tegra_dev @@ -26,10 +26,7 @@ ENV EE_DEVICE cuda:0 # configure default config_startup file ENV EE_CONFIG .config_startup.json -## The following line should NOT be moved to based as it should always be updated -RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision -## END do not move - +RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir --no-deps naeural-core CMD ["python3","device.py"] diff --git a/extensions/business/mixins/telegram_mixin.py b/extensions/business/mixins/telegram_mixin.py new file mode 100644 index 00000000..74b635ce --- /dev/null +++ b/extensions/business/mixins/telegram_mixin.py @@ -0,0 +1,290 @@ +import traceback +import threading +import time +import asyncio +import json + +import telegram +from telegram import Update, Message +from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes + + + +__VERSION__ = '4.0.3' + + +class _TelegramChatbotMixin(object): + + def __add_user_info(self, user, question): + if user not in self.__stats: + self.__stats[user] = { + 'questions': 0, + 'last_question': None, + # 'last_answer': None, + } + self.__stats[user]['questions'] += 1 + self.__stats[user]['last_question'] = question + return + + + + def __reply_wrapper(self, question, user): + self.__add_user_info(user=user, question=question) + result = self.__message_handler(message=question, user=user) + return result + + + async def __handle_response(self, user: str, text: str) -> str: + self.bot_log(" Preparing response for {}...".format(user), low_priority=True) + # Create your own response logic + question: str = text.lower() + usr = str(user).lower() + loop = asyncio.get_running_loop() + answer = await loop.run_in_executor( + None, # Use the default executor (a ThreadPoolExecutor) + self.__reply_wrapper, + question, + usr + ) + return answer + + + async def __handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + message : Message = update.message + if message is None: + return + + # Get basic info of the incoming message + message_type: str = message.chat.type + text: str = message.text + bot_name : str = self.__bot_name + + chat_id = update.effective_message.chat_id + initiator_id = message.from_user.id + + if message.from_user.first_name is not None: + initiator_name = message.from_user.first_name + else: + initiator_name = initiator_id + + is_bot_in_text = bot_name in text + text = text.replace(bot_name , '').strip() + chat_name = message.chat.title + + if self.bot_debug: + self.bot_log( + f'User {initiator_name} ({initiator_id}) in `{chat_name}` ({message_type}): "{text}"', + low_priority=True + ) + + allow = False + # React to group messages only if users mention the bot directly + if message_type in ['group', 'supergroup']: + if is_bot_in_text: + allow = True + else: + reply_to = message.reply_to_message + if reply_to is not None: + self.bot_log(f"Reply from '{initiator_name}' to {reply_to.from_user} ", low_priority=True) + if reply_to.from_user.is_bot: + allow = True + else: + chat_name = initiator_name + allow = True + + if not allow: + return + + if self.bot_debug: + # Print a log for debugging + self.bot_log( + f'User {initiator_name} ({initiator_id}) in `{chat_name}` ({message_type}): "{text}"', + low_priority=True + ) + + + await context.bot.send_chat_action(chat_id=chat_id, action=telegram.constants.ChatAction.TYPING) + + # next line is the main logic of the bot + response: str = await self.__handle_response(user=initiator_id, text=text) + + # Reply normal if the message is in private + self.bot_log(' Bot resp: {}'.format(response), color='m', low_priority=True) + await message.reply_text(response) + if self.bot_debug: + self.bot_dump_stats() + return + + + # Log errors + async def __on_error(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + exc = traceback.format_exc() + msg = ( + f'Update {update} caused error {context.error}\n\n' + f'Bot: {context.bot.name} {context.bot.username} {context.bot.first_name} {context.bot.last_name}\n' + f'Bot data: {context.bot_data}\n' + f'Chat data: {context.chat_data}\n' + f'User data: {context.user_data}\n' + f'Trace: {exc}' + ) + self.bot_log(msg, color='r') + return + + + def __bot_runner(self): + # Create and set a new event loop for this thread + self.bot_log("Creating asyncio loop...") + self.__asyncio_loop = asyncio.new_event_loop() + self.bot_log("Setting asyncio loop...") + asyncio.set_event_loop(self.__asyncio_loop) + + # Create an asyncio Event to signal stopping + self.__stop_event = asyncio.Event() + + try: + # Run the bot's main coroutine + self.bot_log("Running bot async loop run_until_complete ...") + self.__asyncio_loop.run_until_complete(self.__run_bot()) + self.bot_log("Bot main coroutine finished.") + except Exception as e: + self.bot_log(f"Error in bot_runner: {e}", color='r') + finally: + self.bot_log("Closing asyncio loop...") + # Shutdown asynchronous generators + self.__asyncio_loop.run_until_complete(self.__asyncio_loop.shutdown_asyncgens()) + # Close the event loop + self.__asyncio_loop.close() + + self.bot_log("Bot runner thread exit.", color='g') + return + + + async def __run_bot(self): + self.__app = Application.builder().token(self.__token).build() + + # Add handlers + self.__app.add_handler(MessageHandler(filters.TEXT, self.__handle_message)) + self.__app.add_error_handler(self.__on_error) + + # Initialize and start the bot + await self.__app.initialize() + await self.__app.start() + self.bot_log('Bot started.') + + # Start polling without installing signal handlers + await self.__app.updater.start_polling(poll_interval=3) + + # Wait until the stop event is set + await self.__stop_event.wait() + + # Stop the updater and the application + await self.__app.updater.stop() + await self.__app.stop() + await self.__app.shutdown() + self.bot_log('Bot stopped.') + return + + + def __run_threaded(self): + self.__running_threaded = True + obfuscated_token = self.__token[:5] + '...' + self.__token[-5:] + self.bot_log("Starting bot...") + self.__bot_thread = threading.Thread(target=self.__bot_runner) + self.__bot_thread.start() + time.sleep(2) + self.bot_log("Started {} using {} v{}, token {}...".format( + self.__bot_name, self.__class__.__name__, __VERSION__, obfuscated_token + ), + color='g', boxed=True + ) + return + + + + def __run_blocking(self): + self.__running_threaded = False + self.bot_log("Starting bot...") + self.bot_runner() + return + + + ## Public methods + + def bot_dump_stats(self): + self.bot_log("Bot stats:\n{}".format(json.dumps(self.__stats, indent=2))) + return + + def bot_stop(self): + self.bot_log("Stopping bot...", color='r') + if self.__asyncio_loop is not None and self.__stop_event is not None: + self.bot_log("Signaling bot to stop...") + self.__stop_event.set() + if self.__bot_thread is not None: + self.bot_log("Waiting for bot thread to join...") + self.__bot_thread.join() + self.bot_log("Bot stopped.", color='g') + return + + + + def bot_run(self): + if self.__running_threaded: + self.__run_threaded() + else: + self.__run_blocking() + return + + + def bot_log(self, s, color=None, low_priority=False, **kwargs): + if low_priority and not self.bot_debug: + return + self.P(s, color=color, **kwargs) + return + + + def bot_build( + self, + token, + bot_name, + message_handler, + run_threaded=True, + bot_debug=False + ): + """ + Builds a Telegram bot with the given token and name. + + Parameters: + ---------- + + token : str + The token of the bot. + + bot_name : str + The name of the bot. + + messge_handler : function + The function that will handle the messages having the following signature: + `message_handler(message: str, user: str) -> str` + + run_threaded : bool + If True, the bot will run in a separate thread as it is recommended in the plugin system. + + """ + self.__app : Application = None + self.__bot_thread = None + self.__asyncio_loop = None + self.__stats = {} + self.bot_debug = bot_debug + self.__token = token + self.__bot_name = bot_name + self.__message_handler = message_handler + + self.bot_runner_version = __VERSION__ + self.__running_threaded = run_threaded + + + self.bot_log("Starting up {} '{}' v{}...".format( + self.__class__.__name__,self.__bot_name, __VERSION__ + ), color='g', boxed=True + ) + return \ No newline at end of file diff --git a/extensions/business/telegram/basic_telegram_bot_01.py b/extensions/business/telegram/basic_telegram_bot_01.py new file mode 100644 index 00000000..750cacf5 --- /dev/null +++ b/extensions/business/telegram/basic_telegram_bot_01.py @@ -0,0 +1,89 @@ +from naeural_core.business.base import BasePluginExecutor as BasePlugin +from extensions.business.mixins.telegram_mixin import _TelegramChatbotMixin + +_CONFIG = { + **BasePlugin.CONFIG, + + "PROCESS_DELAY" : 5, + "ALLOW_EMPTY_INPUTS" : True, + + "SEND_STATUS_EACH" : 60, + + "TELEGRAM_BOT_NAME" : None, + "TELEGRAM_BOT_TOKEN" : None, + "MESSAGE_HANDLER" : None, + "MESSAGE_HANDLER_ARGS" : [], + "MESSAGE_HANDLER_NAME" : None, + + + 'VALIDATION_RULES' : { + **BasePlugin.CONFIG['VALIDATION_RULES'], + }, +} + +class BasicTelegramBot01Plugin( + BasePlugin, + _TelegramChatbotMixin, + ): + CONFIG = _CONFIG + + def __create_custom_reply_executor(self, str_base64_code, lst_arguments): + self.P(f"Preparing custom reply executor with arguments: {lst_arguments}...") + # + self.__custom_handler, errors, warnings = self._get_method_from_custom_code( + str_b64code=str_base64_code, + self_var='plugin', + method_arguments=['plugin'] + lst_arguments, + + debug=True, + ) + # + if errors: + self.P(f"Errors found in custom reply executor: {errors}") + if warnings: + self.P(f"Warnings found in custom reply executor: {warnings}") + if self.__custom_handler is None: + self.P("Custom reply executor could not be created", color='r') + else: + self.P(f"Custom reply executor created: {self.__custom_handler}") + return + + def on_init(self): + self.__token = self.cfg_telegram_bot_token + self.__bot_name = self.cfg_telegram_bot_name + + + self.__last_status_check = 0 + self.__create_custom_reply_executor( + str_base64_code=self.cfg_message_handler, + lst_arguments=self.cfg_message_handler_args, + ) + + if self.__custom_handler is not None: + self.P("Building and running the Telegram bot...") + self.bot_build( + token=self.__token, + bot_name=self.__bot_name, + message_handler=self.bot_msg_handler, + run_threaded=True, + ) + self.bot_run() + self.__failed = False + else: + self.P("Custom reply executor could not be created, bot will not run", color='r') + self.__failed = True + raise ValueError("Custom reply executor could not be created") + return + + def bot_msg_handler(self, message, user, **kwargs): + result = self.__custom_handler(plugin=self, message=message, user=user) + return result + + + def process(self): + payload = None + if (self.time() - self.__last_status_check) > self.cfg_send_status_each: + self.__last_status_check = self.time() + if not self.__failed: + self.bot_dump_stats() + return payload \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..c691936d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +kmonitor +naeural_client +decentra-vision +python-telegram-bot \ No newline at end of file diff --git a/ver.py b/ver.py index 00bf427b..e828035a 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.47' +__VER__ = '2.0.48' From 337d673903d9cefd6f3bf002a0136d651b5424b1 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Tue, 12 Nov 2024 15:36:59 +0200 Subject: [PATCH 24/61] feat: convers tel bot template --- extensions/business/mixins/telegram_mixin.py | 13 ++-- ...ram_bot_01.py => telegram_basic_bot_01.py} | 10 +-- .../telegram_conversational_bot_01.py | 67 +++++++++++++++++++ ver.py | 2 +- 4 files changed, 83 insertions(+), 9 deletions(-) rename extensions/business/telegram/{basic_telegram_bot_01.py => telegram_basic_bot_01.py} (98%) create mode 100644 extensions/business/telegram/telegram_conversational_bot_01.py diff --git a/extensions/business/mixins/telegram_mixin.py b/extensions/business/mixins/telegram_mixin.py index 74b635ce..788dd447 100644 --- a/extensions/business/mixins/telegram_mixin.py +++ b/extensions/business/mixins/telegram_mixin.py @@ -217,11 +217,11 @@ def bot_dump_stats(self): def bot_stop(self): self.bot_log("Stopping bot...", color='r') if self.__asyncio_loop is not None and self.__stop_event is not None: - self.bot_log("Signaling bot to stop...") - self.__stop_event.set() + self.bot_log("Signaling bot to stop...") + self.__stop_event.set() if self.__bot_thread is not None: - self.bot_log("Waiting for bot thread to join...") - self.__bot_thread.join() + self.bot_log("Waiting for bot thread to join...") + self.__bot_thread.join() self.bot_log("Bot stopped.", color='g') return @@ -287,4 +287,9 @@ def bot_build( self.__class__.__name__,self.__bot_name, __VERSION__ ), color='g', boxed=True ) + return + + def on_close(self): + self.P("Initiating bot shutdown procedure...") + self.bot_stop() return \ No newline at end of file diff --git a/extensions/business/telegram/basic_telegram_bot_01.py b/extensions/business/telegram/telegram_basic_bot_01.py similarity index 98% rename from extensions/business/telegram/basic_telegram_bot_01.py rename to extensions/business/telegram/telegram_basic_bot_01.py index 750cacf5..37be3af3 100644 --- a/extensions/business/telegram/basic_telegram_bot_01.py +++ b/extensions/business/telegram/telegram_basic_bot_01.py @@ -21,9 +21,9 @@ }, } -class BasicTelegramBot01Plugin( - BasePlugin, +class TelegramBasicBot01Plugin( _TelegramChatbotMixin, + BasePlugin, ): CONFIG = _CONFIG @@ -48,11 +48,11 @@ def __create_custom_reply_executor(self, str_base64_code, lst_arguments): self.P(f"Custom reply executor created: {self.__custom_handler}") return + def on_init(self): self.__token = self.cfg_telegram_bot_token self.__bot_name = self.cfg_telegram_bot_name - - + self.__last_status_check = 0 self.__create_custom_reply_executor( str_base64_code=self.cfg_message_handler, @@ -75,6 +75,8 @@ def on_init(self): raise ValueError("Custom reply executor could not be created") return + + def bot_msg_handler(self, message, user, **kwargs): result = self.__custom_handler(plugin=self, message=message, user=user) return result diff --git a/extensions/business/telegram/telegram_conversational_bot_01.py b/extensions/business/telegram/telegram_conversational_bot_01.py new file mode 100644 index 00000000..620df848 --- /dev/null +++ b/extensions/business/telegram/telegram_conversational_bot_01.py @@ -0,0 +1,67 @@ +from naeural_core.business.base import BasePluginExecutor as BasePlugin +from extensions.business.mixins.telegram_mixin import _TelegramChatbotMixin + +_CONFIG = { + **BasePlugin.CONFIG, + + "PROCESS_DELAY" : 5, + "ALLOW_EMPTY_INPUTS" : True, + + "SEND_STATUS_EACH" : 60, + + "TELEGRAM_BOT_NAME" : None, + "TELEGRAM_BOT_TOKEN" : None, + + "API_TOKEN" : None, + "SYSTEM_PROMPT" : None, + "AGENT_TYPE" : "API", + "RAG_SOURCE_URL" : None, + + + 'VALIDATION_RULES' : { + **BasePlugin.CONFIG['VALIDATION_RULES'], + }, +} + +class TelegramConversationalBot01Plugin( + _TelegramChatbotMixin, + BasePlugin, + ): + CONFIG = _CONFIG + + + def on_init(self): + self.__token = self.cfg_telegram_bot_token + self.__bot_name = self.cfg_telegram_bot_name + + self.__last_status_check = 0 + + if self.__custom_handler is not None: + self.P("Building and running the Telegram bot...") + self.bot_build( + token=self.__token, + bot_name=self.__bot_name, + message_handler=self.bot_msg_handler, + run_threaded=True, + ) + self.bot_run() + self.__failed = False + else: + self.P("Custom reply executor could not be created, bot will not run", color='r') + self.__failed = True + raise ValueError("Custom reply executor could not be created") + return + + def bot_msg_handler(self, message, user, **kwargs): + result = message + # here the request-wait-response logic + return result + + + def process(self): + payload = None + if (self.time() - self.__last_status_check) > self.cfg_send_status_each: + self.__last_status_check = self.time() + if not self.__failed: + self.bot_dump_stats() + return payload \ No newline at end of file diff --git a/ver.py b/ver.py index e828035a..0ad725cf 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.48' +__VER__ = '2.0.49' From 457883ed62ccea94929142cb012ae4e9ee9ed9af Mon Sep 17 00:00:00 2001 From: Cristi Bleotiu Date: Tue, 12 Nov 2024 20:08:21 +0200 Subject: [PATCH 25/61] feat: prompt update for banking_assistant -should help reduce the non-relevant responses --- .../fastapi/assistant/naeural_assistant.py | 13 +++++++++---- .../bank_assistant_ro/banking_assistant.py | 16 ++++++++++++++++ ver.py | 2 +- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/extensions/business/fastapi/assistant/naeural_assistant.py b/extensions/business/fastapi/assistant/naeural_assistant.py index 97afc465..26347daf 100644 --- a/extensions/business/fastapi/assistant/naeural_assistant.py +++ b/extensions/business/fastapi/assistant/naeural_assistant.py @@ -116,12 +116,12 @@ def process_response_payload(self, payload_data): 'model_name': processed_data.get('model_name'), } - def get_agent_pipelines(self, node_id, agent_type='llm'): + def get_agent_pipelines(self, node_addr, agent_type='llm'): """ Here, given a node, a list of all the pipelines containing an LLM agent is returned. Parameters ---------- - node_id : str - the node id + node_addr : str - the node address agent_type : str - the type of agent to look for Returns @@ -129,7 +129,8 @@ def get_agent_pipelines(self, node_id, agent_type='llm'): pipelines : list[Pipeline] - a list of pipelines containing an LLM agent """ res = [] - lst_active = self.session.get_active_pipelines(node_id) + # TODO: replace with self.netmon.network_node_pipelines(node_addr) after possible refactoring + lst_active = self.session.get_active_pipelines(node_addr) relevant_signatures = self.get_relevant_plugin_signatures(agent_type=agent_type) for pipeline_id, pipeline in lst_active.items(): plugin_instances = pipeline.lst_plugin_instances @@ -155,10 +156,12 @@ def get_allowed_agents(self, agent_type='llm'): ------- node_ids : list[str] - a list of node ids that are allowed to process the requests of the specified type. """ - lst_allowed = self.session.get_allowed_nodes() + # lst_allowed = self.session.get_allowed_nodes() + lst_allowed = self.netmon.accessible_nodes self.P(f"Allowed nodes: {lst_allowed}") lst_online_agents = [self.get_agent_pipelines(x, agent_type=agent_type) for x in lst_allowed] lst_online_agents = sum(lst_online_agents, []) + # This may also need refactoring after switching to netmon API self.P(f"Online agents: {[(x.node_addr, x.name) for x in lst_online_agents]}") return lst_online_agents @@ -255,6 +258,7 @@ def send_network_request(self, request_id, pipeline, body, request_type, **kwarg """ to_send = self.compute_request_body(request_id, body, request_type=request_type) + # Maybe refactor after switching to netmon API pipeline.send_pipeline_command(to_send, wait_confirmation=False) return @@ -307,6 +311,7 @@ def process_request(self, body, request_type: str = 'llm', conversation_id: str 'error': f'No {request_type.upper()} agents({needed_signatures}) are online. Please try again later.' } # endif no online agents + # Maybe refactor after switching to netmon API pipeline = self.np.random.choice(lst_online_agents) self.register_network_request( request_id=request_id, diff --git a/extensions/business/fastapi/bank_assistant_ro/banking_assistant.py b/extensions/business/fastapi/bank_assistant_ro/banking_assistant.py index 7398e1ab..a2af1c1b 100644 --- a/extensions/business/fastapi/bank_assistant_ro/banking_assistant.py +++ b/extensions/business/fastapi/bank_assistant_ro/banking_assistant.py @@ -27,3 +27,19 @@ def __init__(self, **kwargs): def relevant_plugin_signatures_llm(self): return ['ro_llama_agent'] + + def process_sys_info(self, system_info: str = None, **kwargs): + """ + Process the system information before sending it to the agent. + Parameters + ---------- + system_info : str - the system information from the request + + Returns + ------- + res : str - the system information + """ + sys_info = super(BankingAssistantPlugin, self).process_sys_info(system_info, **kwargs) + if len(sys_info) == 0: + return "Esti un asistent bancar excelent care raspunde concis si vrea sa ajute oamenii." + return f"Esti un asistent bancar excelent si raspunzi concis luand mereu in considerare: {sys_info}" diff --git a/ver.py b/ver.py index 0ad725cf..ad532e2d 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.49' +__VER__ = '2.0.50' From 14f819e7fb0398854d2a13512775bd519d8186de Mon Sep 17 00:00:00 2001 From: Cristi Bleotiu Date: Tue, 12 Nov 2024 20:15:55 +0200 Subject: [PATCH 26/61] chore: update naeural_core --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index ad532e2d..71bcca4e 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.50' +__VER__ = '2.0.51' From 3c6633df8c876bb8bef5e4667c23e391dc97f1de Mon Sep 17 00:00:00 2001 From: Cristi Bleotiu Date: Tue, 12 Nov 2024 21:14:33 +0200 Subject: [PATCH 27/61] chore: update naeural_core --- .../fastapi/assistant/naeural_assistant.py | 5 +++-- .../bank_assistant_ro/banking_assistant.py | 21 ++++++++++++------- ver.py | 2 +- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/extensions/business/fastapi/assistant/naeural_assistant.py b/extensions/business/fastapi/assistant/naeural_assistant.py index 26347daf..65d5db71 100644 --- a/extensions/business/fastapi/assistant/naeural_assistant.py +++ b/extensions/business/fastapi/assistant/naeural_assistant.py @@ -156,8 +156,9 @@ def get_allowed_agents(self, agent_type='llm'): ------- node_ids : list[str] - a list of node ids that are allowed to process the requests of the specified type. """ - # lst_allowed = self.session.get_allowed_nodes() - lst_allowed = self.netmon.accessible_nodes + lst_allowed = self.session.get_allowed_nodes() + # TODO: check why the following line produces a different result than the one above. + # lst_allowed = self.netmon.accessible_nodes self.P(f"Allowed nodes: {lst_allowed}") lst_online_agents = [self.get_agent_pipelines(x, agent_type=agent_type) for x in lst_allowed] lst_online_agents = sum(lst_online_agents, []) diff --git a/extensions/business/fastapi/bank_assistant_ro/banking_assistant.py b/extensions/business/fastapi/bank_assistant_ro/banking_assistant.py index a2af1c1b..a2b27e76 100644 --- a/extensions/business/fastapi/bank_assistant_ro/banking_assistant.py +++ b/extensions/business/fastapi/bank_assistant_ro/banking_assistant.py @@ -1,3 +1,5 @@ +from urllib3 import request + from extensions.business.fastapi.assistant.naeural_assistant import NaeuralAssistantPlugin as BasePlugin @@ -28,18 +30,21 @@ def __init__(self, **kwargs): def relevant_plugin_signatures_llm(self): return ['ro_llama_agent'] - def process_sys_info(self, system_info: str = None, **kwargs): + def compute_request_body_llm(self, request_id, body): """ - Process the system information before sending it to the agent. + Compute the request body to be sent to the agent's pipeline. Parameters ---------- - system_info : str - the system information from the request + request_id : str - the request id + body : dict - the request body Returns ------- - res : str - the system information + to_send : dict - the request body to be sent to the agent's pipeline """ - sys_info = super(BankingAssistantPlugin, self).process_sys_info(system_info, **kwargs) - if len(sys_info) == 0: - return "Esti un asistent bancar excelent care raspunde concis si vrea sa ajute oamenii." - return f"Esti un asistent bancar excelent si raspunzi concis luand mereu in considerare: {sys_info}" + request_dict = super(BankingAssistantPlugin, self).compute_request_body_llm(request_id, body) + context = request_dict['STRUCT_DATA'][0]['system_info'] + request_dict['STRUCT_DATA'][0]['context'] = context + request_dict['STRUCT_DATA'][0]['system_info'] = "Esti un asistent bancar excelent care raspunde concis si vrea sa ajute oamenii." + return request_dict + diff --git a/ver.py b/ver.py index 71bcca4e..eefed481 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.51' +__VER__ = '2.0.52' From cf38fe086cc586652371ec0e80afa920d680df4a Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Tue, 12 Nov 2024 22:24:02 +0200 Subject: [PATCH 28/61] fix: network loopback demo --- .../network_consumer_loopback_demo.py | 77 +++++++++++++++++++ ver.py | 2 +- 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 plugins/business/tutorials/network_consumer_loopback_demo.py diff --git a/plugins/business/tutorials/network_consumer_loopback_demo.py b/plugins/business/tutorials/network_consumer_loopback_demo.py new file mode 100644 index 00000000..316d0274 --- /dev/null +++ b/plugins/business/tutorials/network_consumer_loopback_demo.py @@ -0,0 +1,77 @@ +""" +{ + "NAME" : "network_consumer_demo", + "TYPE" : "IotQueueListener", + + "PATH_FILTER" : [null, null, "NETWORK_CONSUMER_LOOPBACK_DEMO", null], + "MESSAGE_FILTER" : {}, + + "PLUGINS" : [ + { + "SIGNATURE" : "NETWORK_CONSUMER_LOOPBACK_DEMO", + "INSTANCES" : [ + { + "INSTANCE_ID" : "NETWORK_CONSUMER_DEMO_INST1" + } + ] + } + ] +} + +""" +from naeural_core.business.base import BasePluginExecutor as BasePlugin + + +__VER__ = '0.1.0' + +_CONFIG = { + + **BasePlugin.CONFIG, + 'ALLOW_EMPTY_INPUTS' : True, + + 'SEND_EACH' : 20, + + 'VALIDATION_RULES' : { + **BasePlugin.CONFIG['VALIDATION_RULES'], + }, +} + +class NetworkConsumerLoopbackDemoPlugin(BasePlugin): + + + def on_init(self): + self.P("Network consumer loop-back demo initialized") + self.__last_data_time = 0 + self.__data_id = 0 + return + + def __maybe_send(self): + if self.time() - self.__last_data_time > self.cfg_send_each: + self.__last_data_time = self.time() + self.__data_id += 1 + self.P("Sending data with id: {}".format(self.__data_id)) + self.add_payload_by_fields( + data_id=self.__data_id, + data_json={ + "data_id_bis" : self.__data_id, + "data" : "some data", + } + ) + return + + def __maybe_process_received(self): + data = self.dataapi_struct_data() + if data is not None: + filtered_data = { + k : v for k, v in data.items() if k in ['EE_SENDER', "DATA_ID", "DATA_JSON", "EE_TIMESTAMP"] + } + self.P("Received data:\n{}".format(self.json_dumps(data, indent=2))) + return + + + def process(self): + payload = None + self.__maybe_send() + self.__maybe_process_received() + return payload + diff --git a/ver.py b/ver.py index eefed481..912463e8 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.52' +__VER__ = '2.0.53' From f5d2f7bf31869e97df514bb3bcb2018e193c58e9 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Tue, 12 Nov 2024 22:35:54 +0200 Subject: [PATCH 29/61] fix: update net loopback demo --- plugins/business/tutorials/network_consumer_loopback_demo.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/business/tutorials/network_consumer_loopback_demo.py b/plugins/business/tutorials/network_consumer_loopback_demo.py index 316d0274..48933210 100644 --- a/plugins/business/tutorials/network_consumer_loopback_demo.py +++ b/plugins/business/tutorials/network_consumer_loopback_demo.py @@ -29,6 +29,8 @@ **BasePlugin.CONFIG, 'ALLOW_EMPTY_INPUTS' : True, + 'PROCESS_DELAY' : 0, + 'SEND_EACH' : 20, 'VALIDATION_RULES' : { @@ -65,7 +67,7 @@ def __maybe_process_received(self): filtered_data = { k : v for k, v in data.items() if k in ['EE_SENDER', "DATA_ID", "DATA_JSON", "EE_TIMESTAMP"] } - self.P("Received data:\n{}".format(self.json_dumps(data, indent=2))) + self.P("Received data:\n{}".format(self.json_dumps(filtered_data, indent=2))) return From df68a69f6bdbbb81c5d774572b3a0c1be8dfd754 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Tue, 12 Nov 2024 22:54:27 +0200 Subject: [PATCH 30/61] fix: multi-signature filtering --- .devcontainer/Dockerfile | 1 + .devcontainer/devcontainer.json | 2 ++ .../tutorials/network_consumer_loopback_demo.py | 15 +++++++++++---- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index b52fbc1b..6c242fc1 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -27,6 +27,7 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # configure default config_startup file ENV EE_CONFIG .config_startup.json + ## The following line should NOT be moved to based as it should always be updated RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision python-telegram-bot ## END do not move diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 90d9dc1c..cba18ef2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,12 +3,14 @@ "dockerFile" : "Dockerfile", // "image": "aidamian/ds101_2024", + "runArgs": [ //"--gpus=all", "--hostname", "edge_dev" ], + "customizations": { "vscode" : { diff --git a/plugins/business/tutorials/network_consumer_loopback_demo.py b/plugins/business/tutorials/network_consumer_loopback_demo.py index 48933210..8829cd0e 100644 --- a/plugins/business/tutorials/network_consumer_loopback_demo.py +++ b/plugins/business/tutorials/network_consumer_loopback_demo.py @@ -1,9 +1,13 @@ """ { "NAME" : "network_consumer_demo", - "TYPE" : "IotQueueListener", + "TYPE" : "NetworkListener", - "PATH_FILTER" : [null, null, "NETWORK_CONSUMER_LOOPBACK_DEMO", null], + "PATH_FILTER" : [ + null, null, + ["NETWORK_CONSUMER_LOOPBACK_DEMO", "NET_MON_01"], + null + ], "MESSAGE_FILTER" : {}, "PLUGINS" : [ @@ -64,10 +68,13 @@ def __maybe_send(self): def __maybe_process_received(self): data = self.dataapi_struct_data() if data is not None: + eeid = data.get('EE_ID', None) filtered_data = { - k : v for k, v in data.items() if k in ['EE_SENDER', "DATA_ID", "DATA_JSON", "EE_TIMESTAMP"] + k : v for k, v in data.items() if k in [ + 'EE_SENDER', "DATA_ID", "DATA_JSON", "EE_TIMESTAMP", "SIGNATURE" + ] } - self.P("Received data:\n{}".format(self.json_dumps(filtered_data, indent=2))) + self.P("Received data from '{}':\n{}".format(eeid, self.json_dumps(filtered_data, indent=2))) return From 5d3028bb14a01b26c0496bd59c2d9f046587ffe7 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Wed, 13 Nov 2024 11:12:45 +0200 Subject: [PATCH 31/61] feat: work on network nodes configuration demo --- .devcontainer/Dockerfile | 1 + .devcontainer/devcontainer.json | 2 - plugins/business/tutorials/net_config_demo.py | 103 ++++++++++++++++++ ..._demo.py => net_consumer_loopback_demo.py} | 6 +- 4 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 plugins/business/tutorials/net_config_demo.py rename plugins/business/tutorials/{network_consumer_loopback_demo.py => net_consumer_loopback_demo.py} (91%) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 6c242fc1..e9bbbfd5 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -7,6 +7,7 @@ COPY . /edge_node # set a generic env variable ENV AINODE_DOCKER Yes + # set a generic env variable ENV AINODE_DOCKER_SOURCE main diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index cba18ef2..53658d51 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,7 +3,6 @@ "dockerFile" : "Dockerfile", // "image": "aidamian/ds101_2024", - "runArgs": [ //"--gpus=all", "--hostname", @@ -11,7 +10,6 @@ ], - "customizations": { "vscode" : { "extensions": [ diff --git a/plugins/business/tutorials/net_config_demo.py b/plugins/business/tutorials/net_config_demo.py new file mode 100644 index 00000000..204150ba --- /dev/null +++ b/plugins/business/tutorials/net_config_demo.py @@ -0,0 +1,103 @@ +""" +{ + "NAME" : "peer_config_demo", + "TYPE" : "NetworkListener", + + "PATH_FILTER" : [ + null, null, + ["UPDATE_MONITOR_01", "NET_MON_01"], + null + ], + "MESSAGE_FILTER" : {}, + + "PLUGINS" : [ + { + "SIGNATURE" : "NET_CONFIG_DEMO", + "INSTANCES" : [ + { + "INSTANCE_ID" : "NET_CONFIG_DEMO_INST1" + } + ] + } + ] +} + +""" +from naeural_core.business.base import BasePluginExecutor as BasePlugin + + +__VER__ = '0.1.0' + +_CONFIG = { + + **BasePlugin.CONFIG, + 'ALLOW_EMPTY_INPUTS' : True, + + 'PROCESS_DELAY' : 0, + + 'SEND_EACH' : 5, + + 'VALIDATION_RULES' : { + **BasePlugin.CONFIG['VALIDATION_RULES'], + }, +} + +class PeerConfigDemoPlugin(BasePlugin): + + + def on_init(self): + self.P("Network peer config watch demo initializing...") + self.__last_data_time = 0 + self.__allowed_nodes = {} + return + + def __maybe_send(self): + if self.time() - self.__last_data_time > self.cfg_send_each: + self.P("I have {} pipelines locally. Sending requests to all nodes...".format( + len(self.local_pipelines) + )) + self.__last_data_time = self.time() + # now send some requests + for node in self.__allowed_nodes: + addr = node + self.cmdapi_send_instance_command( + pipeline="admin_pipeline", + signature="UPDATE_MONITOR_01", + instance_id="UPDATE_MONITOR_01_INST", + instance_command="GET_PIPELINES", + node_address=addr, + ) + return + + def __maybe_process_received(self): + data = self.dataapi_struct_data() + if data is not None: + eeid = data.get(self.const.PAYLOAD_DATA.EE_ID, None) + sender = data.get(self.const.PAYLOAD_DATA.EE_SENDER, None) + signature = data.get(self.const.PAYLOAD_DATA.SIGNATURE, None) + is_encrypted = data.get(self.const.PAYLOAD_DATA.EE_IS_ENCRYPTED, False) + self.P("Received {} '{}' data from '{}' <{}>".format( + "encrypted" if is_encrypted else "unencrypted", + signature, eeid, sender + )) + if signature == "NET_MON_01": + nodes_data = data.get("CURRENT_NETWORK") + if nodes_data is not None: + self.P("Received NET_MON_01 net-map data for {} nodes. Here is one of them:\n{}".format( + len(nodes_data), nodes_data[list(nodes_data.keys())[0]] + )) + # get all whitelists + # check if ee_addr in whitelist then add to allowed nodes + # + elif signature == "UPDATE_MONITOR_01": + self.P("Received UPDATE_MONITOR_01 data: \n{}".format(data)) + + return + + + def process(self): + payload = None + self.__maybe_send() + self.__maybe_process_received() + return payload + diff --git a/plugins/business/tutorials/network_consumer_loopback_demo.py b/plugins/business/tutorials/net_consumer_loopback_demo.py similarity index 91% rename from plugins/business/tutorials/network_consumer_loopback_demo.py rename to plugins/business/tutorials/net_consumer_loopback_demo.py index 8829cd0e..f1a90098 100644 --- a/plugins/business/tutorials/network_consumer_loopback_demo.py +++ b/plugins/business/tutorials/net_consumer_loopback_demo.py @@ -5,14 +5,14 @@ "PATH_FILTER" : [ null, null, - ["NETWORK_CONSUMER_LOOPBACK_DEMO", "NET_MON_01"], + ["NET_CONSUMER_LOOPBACK_DEMO", "NET_MON_01"], null ], "MESSAGE_FILTER" : {}, "PLUGINS" : [ { - "SIGNATURE" : "NETWORK_CONSUMER_LOOPBACK_DEMO", + "SIGNATURE" : "NET_CONSUMER_LOOPBACK_DEMO", "INSTANCES" : [ { "INSTANCE_ID" : "NETWORK_CONSUMER_DEMO_INST1" @@ -42,7 +42,7 @@ }, } -class NetworkConsumerLoopbackDemoPlugin(BasePlugin): +class NetConsumerLoopbackDemoPlugin(BasePlugin): def on_init(self): From d13a76e03836f3785e38753edd48fb0813ac8144 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Wed, 13 Nov 2024 11:15:19 +0200 Subject: [PATCH 32/61] fix: NetConfigDemoPlugin --- plugins/business/tutorials/net_config_demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/business/tutorials/net_config_demo.py b/plugins/business/tutorials/net_config_demo.py index 204150ba..d9fcca09 100644 --- a/plugins/business/tutorials/net_config_demo.py +++ b/plugins/business/tutorials/net_config_demo.py @@ -42,7 +42,7 @@ }, } -class PeerConfigDemoPlugin(BasePlugin): +class NetConfigDemoPlugin(BasePlugin): def on_init(self): From 561e83f53238a74c734d245fd44f8da9f4184abe Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Wed, 13 Nov 2024 23:41:05 +0200 Subject: [PATCH 33/61] fix: net config demo test --- .devcontainer/Dockerfile | 5 -- .devcontainer/devcontainer.json | 4 +- constants.py | 8 ++ plugins/business/tutorials/net_config_demo.py | 86 +++++++++++++------ ..._loopback_demo.py => net_loopback_demo.py} | 6 +- ver.py | 2 +- 6 files changed, 75 insertions(+), 36 deletions(-) create mode 100644 constants.py rename plugins/business/tutorials/{net_consumer_loopback_demo.py => net_loopback_demo.py} (91%) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index e9bbbfd5..c6d78e33 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -21,14 +21,9 @@ ENV AINODE_ENV_VER $AI_ENV_VER ENV TZ=Europe/Bucharest RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone -# also can use EE_DEVICE to define target such as cuda:0 or cuda:1 instead of cpu -# althouh this is not recommended as it should be in .env file -# ENV EE_DEVICE cuda:0 - # configure default config_startup file ENV EE_CONFIG .config_startup.json - ## The following line should NOT be moved to based as it should always be updated RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision python-telegram-bot ## END do not move diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 53658d51..a3fb17b6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,11 +4,11 @@ // "image": "aidamian/ds101_2024", "runArgs": [ - //"--gpus=all", + //"--gpus=all", // Use this option if you have a GPU "--hostname", "edge_dev" ], - + "customizations": { "vscode" : { diff --git a/constants.py b/constants.py new file mode 100644 index 00000000..df80e8fb --- /dev/null +++ b/constants.py @@ -0,0 +1,8 @@ +# placeholder for edge-node constants + +PROBABLY_NOTHING = 1.618 + + + +if __name__ == '__main__': + print("") diff --git a/plugins/business/tutorials/net_config_demo.py b/plugins/business/tutorials/net_config_demo.py index d9fcca09..2685b16f 100644 --- a/plugins/business/tutorials/net_config_demo.py +++ b/plugins/business/tutorials/net_config_demo.py @@ -35,7 +35,9 @@ 'PROCESS_DELAY' : 0, - 'SEND_EACH' : 5, + 'SEND_EACH' : 10, + + 'REQUEST_CONFIGS_EACH' : 30, 'VALIDATION_RULES' : { **BasePlugin.CONFIG['VALIDATION_RULES'], @@ -48,25 +50,35 @@ class NetConfigDemoPlugin(BasePlugin): def on_init(self): self.P("Network peer config watch demo initializing...") self.__last_data_time = 0 + self.__new_nodes_this_iter = 0 self.__allowed_nodes = {} return def __maybe_send(self): if self.time() - self.__last_data_time > self.cfg_send_each: - self.P("I have {} pipelines locally. Sending requests to all nodes...".format( - len(self.local_pipelines) - )) self.__last_data_time = self.time() - # now send some requests - for node in self.__allowed_nodes: - addr = node - self.cmdapi_send_instance_command( - pipeline="admin_pipeline", - signature="UPDATE_MONITOR_01", - instance_id="UPDATE_MONITOR_01_INST", - instance_command="GET_PIPELINES", - node_address=addr, - ) + if len(self.__allowed_nodes) == 0: + self.P("No allowed nodes to send requests to. Waiting for network data...") + else: + self.P("I have {} pipelines locally. Sending requests to all nodes...".format( + len(self.local_pipelines) + )) + # now send some requests + for node_addr in self.__allowed_nodes: + last_request = self.__allowed_nodes[node_addr].get("last_config_get", 0) + if (self.time() - last_request) > self.cfg_request_configs_each: + self.P("Sending GET_PIPELINES <{}>...".format(node_addr)) + self.cmdapi_send_instance_command( + pipeline="admin_pipeline", + signature="UPDATE_MONITOR_01", + instance_id="UPDATE_MONITOR_01_INST", + instance_command="GET_PIPELINES", + node_address=node_addr, + ) + #endif enough time since last request of this node + #endfor __allowed_nodes + #endif len(__allowed_nodes) == 0 + #endif time to send return def __maybe_process_received(self): @@ -76,21 +88,45 @@ def __maybe_process_received(self): sender = data.get(self.const.PAYLOAD_DATA.EE_SENDER, None) signature = data.get(self.const.PAYLOAD_DATA.SIGNATURE, None) is_encrypted = data.get(self.const.PAYLOAD_DATA.EE_IS_ENCRYPTED, False) - self.P("Received {} '{}' data from '{}' <{}>".format( + self.P("Received {} '{}' data from {}".format( "encrypted" if is_encrypted else "unencrypted", - signature, eeid, sender + signature, f"'{eeid}' <{sender}>" if sender != self.ee_addr else "SELF" )) if signature == "NET_MON_01": nodes_data = data.get("CURRENT_NETWORK") - if nodes_data is not None: - self.P("Received NET_MON_01 net-map data for {} nodes. Here is one of them:\n{}".format( - len(nodes_data), nodes_data[list(nodes_data.keys())[0]] - )) - # get all whitelists - # check if ee_addr in whitelist then add to allowed nodes - # - elif signature == "UPDATE_MONITOR_01": - self.P("Received UPDATE_MONITOR_01 data: \n{}".format(data)) + self.__new_nodes_this_iter = 0 + for node_data in nodes_data.values(): + addr = node_data.get("address", None) + if addr == self.ee_addr: + # its us, no need to check whitelist + continue + whitelist = node_data.get("whitelist", []) + for ee_addr in whitelist: + if ee_addr in self.ee_addr: + # we have found a whitelist that contains our address + if addr not in self.__allowed_nodes: + self.__allowed_nodes[addr] = { + "whitelist" : whitelist, + "last_config_get" : 0 + } + self.__new_nodes_this_iter += 1 + #endif not in allowed nodes + #endif ee_addr in whitelist + # endfor whitelist + #endfor nodes_data + self.P("Found {} new nodes in the network that allow us to send commands.".format( + self.__new_nodes_this_iter + )) + #endif signature == "NET_MON_01" + + elif signature == "UPDATE_MONITOR_01": + self.P("Received UPDATE_MONITOR_01 data: \n{}".format( + self.json_dumps(data, indent=2) + )) + if sender in self.__allowed_nodes: + self.__allowed_nodes[sender]["last_config_get"] = self.time() + self.P(f"Updated last_config_get for node '{sender}'") + #endif signature == "UPDATE_MONITOR_01" return diff --git a/plugins/business/tutorials/net_consumer_loopback_demo.py b/plugins/business/tutorials/net_loopback_demo.py similarity index 91% rename from plugins/business/tutorials/net_consumer_loopback_demo.py rename to plugins/business/tutorials/net_loopback_demo.py index f1a90098..7dee3494 100644 --- a/plugins/business/tutorials/net_consumer_loopback_demo.py +++ b/plugins/business/tutorials/net_loopback_demo.py @@ -5,14 +5,14 @@ "PATH_FILTER" : [ null, null, - ["NET_CONSUMER_LOOPBACK_DEMO", "NET_MON_01"], + ["NET_LOOPBACK_DEMO", "NET_MON_01"], null ], "MESSAGE_FILTER" : {}, "PLUGINS" : [ { - "SIGNATURE" : "NET_CONSUMER_LOOPBACK_DEMO", + "SIGNATURE" : "NET_LOOPBACK_DEMO", "INSTANCES" : [ { "INSTANCE_ID" : "NETWORK_CONSUMER_DEMO_INST1" @@ -42,7 +42,7 @@ }, } -class NetConsumerLoopbackDemoPlugin(BasePlugin): +class NetLoopbackDemoPlugin(BasePlugin): def on_init(self): diff --git a/ver.py b/ver.py index 912463e8..7a26629b 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.53' +__VER__ = '2.0.54' From c3dac46b64af3d1a4275872c345b913379077b0c Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Thu, 14 Nov 2024 08:03:04 +0200 Subject: [PATCH 34/61] fix: sign epochs --- .config_startup.json | 17 +---------------- .../business/epoch_oracle}/epoch_manager_01.py | 11 +++++++++++ plugins/business/tutorials/net_config_demo.py | 12 ++++++------ ver.py | 2 +- 4 files changed, 19 insertions(+), 23 deletions(-) rename {plugins/business => extensions/business/epoch_oracle}/epoch_manager_01.py (95%) diff --git a/.config_startup.json b/.config_startup.json index bd237c39..187df20d 100644 --- a/.config_startup.json +++ b/.config_startup.json @@ -93,17 +93,6 @@ "MINIO_SECURE" : null }, - "REST_CUSTOM_EXEC_01" : { - "ALLOW_EMPTY_INPUTS" : true, - "RUN_WITHOUT_IMAGE" : true, - "SEND_MANIFEST_EACH" : 301 - }, - - "SELF_CHECK_01" : { - "DISK_LOW_PRC" : 0.15, - "MEM_LOW_PRC" : 0.15, - "PROCESS_DELAY" : 5 - }, "NET_MON_01" : { "PROCESS_DELAY" : 10, @@ -123,12 +112,8 @@ "RELEASE_TAG" : "release-tag-for-yaml-config" - }, - - "SYSTEM_HEALTH_MONITOR_01": { - "PROCESS_DELAY" : 180, - "KERNEL_LOG_LEVEL" : "emerg,alert,crit,err" } + }, "COMMUNICATION_ENVIRONMENT" : { diff --git a/plugins/business/epoch_manager_01.py b/extensions/business/epoch_oracle/epoch_manager_01.py similarity index 95% rename from plugins/business/epoch_manager_01.py rename to extensions/business/epoch_oracle/epoch_manager_01.py index 070cfe55..ed40fe91 100644 --- a/plugins/business/epoch_manager_01.py +++ b/extensions/business/epoch_oracle/epoch_manager_01.py @@ -26,7 +26,17 @@ class EpochManager01Plugin(FastApiWebAppPlugin): def __init__(self, **kwargs): super(EpochManager01Plugin, self).__init__(**kwargs) + self.__bc_engine=self.global_shmem[self.ct.BLOCKCHAIN_MANAGER], return + + def __sign(self, data): + """ + Sign the given data using the blockchain engine. + Returns the signature. + Use the data param as it will be modified in place. + """ + signature = self.__bc_engine.sign(data, add_data=True, use_digest=True) + return signature def __get_response(self, dct_data: dict): """ @@ -59,6 +69,7 @@ def __get_response(self, dct_data: dict): dct_data['server_current_epoch'] = self.__get_current_epoch() # TODO: make in the format "84 days, 8:47:51" dct_data['server_uptime'] = str(self.timedelta(seconds=int(self.time_alive))) + self.__sign(dct_data) return dct_data def __get_current_epoch(self): diff --git a/plugins/business/tutorials/net_config_demo.py b/plugins/business/tutorials/net_config_demo.py index 2685b16f..d4d1c688 100644 --- a/plugins/business/tutorials/net_config_demo.py +++ b/plugins/business/tutorials/net_config_demo.py @@ -60,21 +60,21 @@ def __maybe_send(self): if len(self.__allowed_nodes) == 0: self.P("No allowed nodes to send requests to. Waiting for network data...") else: - self.P("I have {} pipelines locally. Sending requests to all nodes...".format( - len(self.local_pipelines) - )) + self.P(f"I have {len(self.local_pipelines)} pipelines locally. Sending requests to all nodes...") # now send some requests for node_addr in self.__allowed_nodes: + node_ee_id = self.netmon.network_node_eeid(node_addr) last_request = self.__allowed_nodes[node_addr].get("last_config_get", 0) if (self.time() - last_request) > self.cfg_request_configs_each: - self.P("Sending GET_PIPELINES <{}>...".format(node_addr)) + self.P(f"Sending GET_PIPELINES to '{node_ee_id}' <{node_addr}>...") self.cmdapi_send_instance_command( pipeline="admin_pipeline", signature="UPDATE_MONITOR_01", instance_id="UPDATE_MONITOR_01_INST", - instance_command="GET_PIPELINES", + instance_command={ "COMMAND": "GET_PIPELINES" }, node_address=node_addr, ) + self.__allowed_nodes[node_addr]["last_config_get"] = self.time() #endif enough time since last request of this node #endfor __allowed_nodes #endif len(__allowed_nodes) == 0 @@ -124,7 +124,7 @@ def __maybe_process_received(self): self.json_dumps(data, indent=2) )) if sender in self.__allowed_nodes: - self.__allowed_nodes[sender]["last_config_get"] = self.time() + # self.P(f"Updated last_config_get for node '{sender}'") #endif signature == "UPDATE_MONITOR_01" diff --git a/ver.py b/ver.py index 7a26629b..5a59ae77 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.54' +__VER__ = '2.0.55' From 979611c225848d675cf9699f2d02d9831c9db040 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Thu, 14 Nov 2024 10:01:41 +0200 Subject: [PATCH 35/61] fix: bc issues --- .devcontainer/Dockerfile | 1 - .devcontainer/devcontainer.json | 1 + .../business/epoch_oracle/epoch_manager_01.py | 3 +- plugins/business/tutorials/net_config_demo.py | 99 ++++++++++++------- ver.py | 2 +- 5 files changed, 68 insertions(+), 38 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index c6d78e33..5945f75e 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -7,7 +7,6 @@ COPY . /edge_node # set a generic env variable ENV AINODE_DOCKER Yes - # set a generic env variable ENV AINODE_DOCKER_SOURCE main diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a3fb17b6..fb904220 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -10,6 +10,7 @@ ], + "customizations": { "vscode" : { "extensions": [ diff --git a/extensions/business/epoch_oracle/epoch_manager_01.py b/extensions/business/epoch_oracle/epoch_manager_01.py index ed40fe91..1cd735d2 100644 --- a/extensions/business/epoch_oracle/epoch_manager_01.py +++ b/extensions/business/epoch_oracle/epoch_manager_01.py @@ -26,7 +26,6 @@ class EpochManager01Plugin(FastApiWebAppPlugin): def __init__(self, **kwargs): super(EpochManager01Plugin, self).__init__(**kwargs) - self.__bc_engine=self.global_shmem[self.ct.BLOCKCHAIN_MANAGER], return def __sign(self, data): @@ -35,7 +34,7 @@ def __sign(self, data): Returns the signature. Use the data param as it will be modified in place. """ - signature = self.__bc_engine.sign(data, add_data=True, use_digest=True) + signature = self.bc.sign(data, add_data=True, use_digest=True) return signature def __get_response(self, dct_data: dict): diff --git a/plugins/business/tutorials/net_config_demo.py b/plugins/business/tutorials/net_config_demo.py index d4d1c688..b7b0f125 100644 --- a/plugins/business/tutorials/net_config_demo.py +++ b/plugins/business/tutorials/net_config_demo.py @@ -60,12 +60,21 @@ def __maybe_send(self): if len(self.__allowed_nodes) == 0: self.P("No allowed nodes to send requests to. Waiting for network data...") else: - self.P(f"I have {len(self.local_pipelines)} pipelines locally. Sending requests to all nodes...") - # now send some requests + self.P("Initiating pipeline requests to allowed nodes...") + to_send = [] for node_addr in self.__allowed_nodes: - node_ee_id = self.netmon.network_node_eeid(node_addr) last_request = self.__allowed_nodes[node_addr].get("last_config_get", 0) if (self.time() - last_request) > self.cfg_request_configs_each: + to_send.append(node_addr) + #endif enough time since last request of this node + #endfor __allowed_nodes + if len(to_send) == 0: + self.P("No nodes need update.") + else: + self.P(f"Local {len(self.local_pipelines)} pipelines. Sending requests to {len(to_send)} nodes...") + # now send some requests + for node_addr in to_send: + node_ee_id = self.netmon.network_node_eeid(node_addr) self.P(f"Sending GET_PIPELINES to '{node_ee_id}' <{node_addr}>...") self.cmdapi_send_instance_command( pipeline="admin_pipeline", @@ -75,54 +84,76 @@ def __maybe_send(self): node_address=node_addr, ) self.__allowed_nodes[node_addr]["last_config_get"] = self.time() - #endif enough time since last request of this node - #endfor __allowed_nodes - #endif len(__allowed_nodes) == 0 + #endfor to_send + #endif len(to_send) == 0 + #endif have allowed nodes #endif time to send return def __maybe_process_received(self): data = self.dataapi_struct_data() if data is not None: - eeid = data.get(self.const.PAYLOAD_DATA.EE_ID, None) + payload_path = data.get(self.const.PAYLOAD_DATA.EE_PAYLOAD_PATH, [None, None, None, None]) + eeid = payload_path[0] + signature = payload_path[2] sender = data.get(self.const.PAYLOAD_DATA.EE_SENDER, None) - signature = data.get(self.const.PAYLOAD_DATA.SIGNATURE, None) is_encrypted = data.get(self.const.PAYLOAD_DATA.EE_IS_ENCRYPTED, False) - self.P("Received {} '{}' data from {}".format( - "encrypted" if is_encrypted else "unencrypted", - signature, f"'{eeid}' <{sender}>" if sender != self.ee_addr else "SELF" + self.P("Received {}'{}' data from {}".format( + "ENC " if is_encrypted else "", + signature, f"'{eeid}' <{sender}>" if sender != self.ee_addr else "SELF", )) if signature == "NET_MON_01": nodes_data = data.get("CURRENT_NETWORK") - self.__new_nodes_this_iter = 0 - for node_data in nodes_data.values(): - addr = node_data.get("address", None) - if addr == self.ee_addr: - # its us, no need to check whitelist - continue - whitelist = node_data.get("whitelist", []) - for ee_addr in whitelist: - if ee_addr in self.ee_addr: - # we have found a whitelist that contains our address - if addr not in self.__allowed_nodes: - self.__allowed_nodes[addr] = { - "whitelist" : whitelist, - "last_config_get" : 0 - } - self.__new_nodes_this_iter += 1 - #endif not in allowed nodes - #endif ee_addr in whitelist - # endfor whitelist - #endfor nodes_data - self.P("Found {} new nodes in the network that allow us to send commands.".format( - self.__new_nodes_this_iter - )) + if nodes_data is None: + self.P("Received NET_MON_01 data without CURRENT_NETWORK data.", color='r ') + else: + self.__new_nodes_this_iter = 0 + for node_data in nodes_data.values(): + addr = node_data.get("address", None) + if addr == self.ee_addr: + # its us, no need to check whitelist + continue + whitelist = node_data.get("whitelist", []) + for ee_addr in whitelist: + if ee_addr in self.ee_addr: + # we have found a whitelist that contains our address + if addr not in self.__allowed_nodes: + self.__allowed_nodes[addr] = { + "whitelist" : whitelist, + "last_config_get" : 0 + } + self.__new_nodes_this_iter += 1 + #endif not in allowed nodes + #endif ee_addr in whitelist + # endfor whitelist + #endfor nodes_data + if self.__new_nodes_this_iter > 0: + self.P(f"Found {self.__new_nodes_this_iter} new nodes in the network that allow us to send commands.") + #endif nodes_data is not None #endif signature == "NET_MON_01" elif signature == "UPDATE_MONITOR_01": self.P("Received UPDATE_MONITOR_01 data: \n{}".format( self.json_dumps(data, indent=2) )) + is_encrypted = data.get(self.const.PAYLOAD_DATA.EE_IS_ENCRYPTED, False) + encrypted_data = data.get(self.const.PAYLOAD_DATA.EE_ENCRYPTED_DATA, None) + if is_encrypted and encrypted_data is not None: + self.P("Received encrypted data. Decrypting...") + str_decrypted_data = self.bc.decrypt_str( + str_b64data=encrypted_data, + str_sender=sender, + ) + decrypted_data = self.json_loads(str_decrypted_data) + if decrypted_data is not None: + self.P("Decrypted data:\n{}".format( + self.json_dumps(decrypted_data, indent=2) + )) + else: + self.P("Failed to decrypt data.", color='r') + #endif decrypted_data is not None + else: + self.P("Received unencrypted data.") if sender in self.__allowed_nodes: # self.P(f"Updated last_config_get for node '{sender}'") diff --git a/ver.py b/ver.py index 5a59ae77..fd00c30d 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.55' +__VER__ = '2.0.56' From f44a506eaceb29e95996ecf2fbfa6de15c418f8b Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Thu, 14 Nov 2024 11:27:19 +0200 Subject: [PATCH 36/61] fix: allowed pipeline config updates --- .devcontainer/Dockerfile | 2 +- .devcontainer/devcontainer.json | 2 -- debug.sh | 2 +- .../{epoch_oracle => oracle_epoch}/epoch_manager_01.py | 0 plugins/business/tutorials/net_config_demo.py | 8 ++++++-- win_debug.bat | 2 +- 6 files changed, 9 insertions(+), 7 deletions(-) rename extensions/business/{epoch_oracle => oracle_epoch}/epoch_manager_01.py (100%) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 5945f75e..a276234e 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -29,4 +29,4 @@ RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision python-te ENV AINODE_DEVCONTAINER Yes -RUN pip install --no-cache-dir --no-deps naeural-core +RUN pip install --no-cache-dir --no-deps naeural-core \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index fb904220..0b6aaf38 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,8 +9,6 @@ "edge_dev" ], - - "customizations": { "vscode" : { "extensions": [ diff --git a/debug.sh b/debug.sh index d7ed915d..b9b47eb3 100755 --- a/debug.sh +++ b/debug.sh @@ -1,2 +1,2 @@ docker build -t local_node -f Dockerfile_dev . -docker run --env-file=.env -v naeural_vol:/edge_node/_local_cache local_node \ No newline at end of file +docker run --rm --env-file=.env -v naeural_vol:/edge_node/_local_cache local_node \ No newline at end of file diff --git a/extensions/business/epoch_oracle/epoch_manager_01.py b/extensions/business/oracle_epoch/epoch_manager_01.py similarity index 100% rename from extensions/business/epoch_oracle/epoch_manager_01.py rename to extensions/business/oracle_epoch/epoch_manager_01.py diff --git a/plugins/business/tutorials/net_config_demo.py b/plugins/business/tutorials/net_config_demo.py index b7b0f125..dbf545c8 100644 --- a/plugins/business/tutorials/net_config_demo.py +++ b/plugins/business/tutorials/net_config_demo.py @@ -33,6 +33,8 @@ **BasePlugin.CONFIG, 'ALLOW_EMPTY_INPUTS' : True, + 'MAX_INPUTS_QUEUE_SIZE' : 16, + 'PROCESS_DELAY' : 0, 'SEND_EACH' : 10, @@ -146,8 +148,10 @@ def __maybe_process_received(self): ) decrypted_data = self.json_loads(str_decrypted_data) if decrypted_data is not None: - self.P("Decrypted data:\n{}".format( - self.json_dumps(decrypted_data, indent=2) + received_pipelines = decrypted_data.get("EE_PIPELINES", []) + self.P("Decrypted data size {} with pipelines:\n{}".format( + len(str_decrypted_data), + self.json_dumps(received_pipelines, indent=2), )) else: self.P("Failed to decrypt data.", color='r') diff --git a/win_debug.bat b/win_debug.bat index f9df6f9a..0c81930c 100644 --- a/win_debug.bat +++ b/win_debug.bat @@ -1,2 +1,2 @@ docker build -t local_node -f Dockerfile_dev . -docker run --gpus=all --env-file=.env -v naeural_vol:/edge_node/_local_cache local_node \ No newline at end of file +docker run --rm --gpus=all --env-file=.env -v naeural_vol:/edge_node/_local_cache local_node \ No newline at end of file From 32d80b3673468325c3ed0e260f687e39ed5597e8 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Thu, 14 Nov 2024 23:42:07 +0200 Subject: [PATCH 37/61] feat: net config monitor ready to move to core --- .devcontainer/Dockerfile | 1 + .devcontainer/devcontainer.json | 1 + plugins/business/tutorials/net_config_demo.py | 174 ------------ .../business/tutorials/net_config_monitor.py | 259 ++++++++++++++++++ ver.py | 2 +- 5 files changed, 262 insertions(+), 175 deletions(-) delete mode 100644 plugins/business/tutorials/net_config_demo.py create mode 100644 plugins/business/tutorials/net_config_monitor.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index a276234e..1456466d 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -29,4 +29,5 @@ RUN pip install --no-cache-dir kmonitor naeural_client decentra-vision python-te ENV AINODE_DEVCONTAINER Yes + RUN pip install --no-cache-dir --no-deps naeural-core \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0b6aaf38..a3fb17b6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,6 +9,7 @@ "edge_dev" ], + "customizations": { "vscode" : { "extensions": [ diff --git a/plugins/business/tutorials/net_config_demo.py b/plugins/business/tutorials/net_config_demo.py deleted file mode 100644 index dbf545c8..00000000 --- a/plugins/business/tutorials/net_config_demo.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -{ - "NAME" : "peer_config_demo", - "TYPE" : "NetworkListener", - - "PATH_FILTER" : [ - null, null, - ["UPDATE_MONITOR_01", "NET_MON_01"], - null - ], - "MESSAGE_FILTER" : {}, - - "PLUGINS" : [ - { - "SIGNATURE" : "NET_CONFIG_DEMO", - "INSTANCES" : [ - { - "INSTANCE_ID" : "NET_CONFIG_DEMO_INST1" - } - ] - } - ] -} - -""" -from naeural_core.business.base import BasePluginExecutor as BasePlugin - - -__VER__ = '0.1.0' - -_CONFIG = { - - **BasePlugin.CONFIG, - 'ALLOW_EMPTY_INPUTS' : True, - - 'MAX_INPUTS_QUEUE_SIZE' : 16, - - 'PROCESS_DELAY' : 0, - - 'SEND_EACH' : 10, - - 'REQUEST_CONFIGS_EACH' : 30, - - 'VALIDATION_RULES' : { - **BasePlugin.CONFIG['VALIDATION_RULES'], - }, -} - -class NetConfigDemoPlugin(BasePlugin): - - - def on_init(self): - self.P("Network peer config watch demo initializing...") - self.__last_data_time = 0 - self.__new_nodes_this_iter = 0 - self.__allowed_nodes = {} - return - - def __maybe_send(self): - if self.time() - self.__last_data_time > self.cfg_send_each: - self.__last_data_time = self.time() - if len(self.__allowed_nodes) == 0: - self.P("No allowed nodes to send requests to. Waiting for network data...") - else: - self.P("Initiating pipeline requests to allowed nodes...") - to_send = [] - for node_addr in self.__allowed_nodes: - last_request = self.__allowed_nodes[node_addr].get("last_config_get", 0) - if (self.time() - last_request) > self.cfg_request_configs_each: - to_send.append(node_addr) - #endif enough time since last request of this node - #endfor __allowed_nodes - if len(to_send) == 0: - self.P("No nodes need update.") - else: - self.P(f"Local {len(self.local_pipelines)} pipelines. Sending requests to {len(to_send)} nodes...") - # now send some requests - for node_addr in to_send: - node_ee_id = self.netmon.network_node_eeid(node_addr) - self.P(f"Sending GET_PIPELINES to '{node_ee_id}' <{node_addr}>...") - self.cmdapi_send_instance_command( - pipeline="admin_pipeline", - signature="UPDATE_MONITOR_01", - instance_id="UPDATE_MONITOR_01_INST", - instance_command={ "COMMAND": "GET_PIPELINES" }, - node_address=node_addr, - ) - self.__allowed_nodes[node_addr]["last_config_get"] = self.time() - #endfor to_send - #endif len(to_send) == 0 - #endif have allowed nodes - #endif time to send - return - - def __maybe_process_received(self): - data = self.dataapi_struct_data() - if data is not None: - payload_path = data.get(self.const.PAYLOAD_DATA.EE_PAYLOAD_PATH, [None, None, None, None]) - eeid = payload_path[0] - signature = payload_path[2] - sender = data.get(self.const.PAYLOAD_DATA.EE_SENDER, None) - is_encrypted = data.get(self.const.PAYLOAD_DATA.EE_IS_ENCRYPTED, False) - self.P("Received {}'{}' data from {}".format( - "ENC " if is_encrypted else "", - signature, f"'{eeid}' <{sender}>" if sender != self.ee_addr else "SELF", - )) - if signature == "NET_MON_01": - nodes_data = data.get("CURRENT_NETWORK") - if nodes_data is None: - self.P("Received NET_MON_01 data without CURRENT_NETWORK data.", color='r ') - else: - self.__new_nodes_this_iter = 0 - for node_data in nodes_data.values(): - addr = node_data.get("address", None) - if addr == self.ee_addr: - # its us, no need to check whitelist - continue - whitelist = node_data.get("whitelist", []) - for ee_addr in whitelist: - if ee_addr in self.ee_addr: - # we have found a whitelist that contains our address - if addr not in self.__allowed_nodes: - self.__allowed_nodes[addr] = { - "whitelist" : whitelist, - "last_config_get" : 0 - } - self.__new_nodes_this_iter += 1 - #endif not in allowed nodes - #endif ee_addr in whitelist - # endfor whitelist - #endfor nodes_data - if self.__new_nodes_this_iter > 0: - self.P(f"Found {self.__new_nodes_this_iter} new nodes in the network that allow us to send commands.") - #endif nodes_data is not None - #endif signature == "NET_MON_01" - - elif signature == "UPDATE_MONITOR_01": - self.P("Received UPDATE_MONITOR_01 data: \n{}".format( - self.json_dumps(data, indent=2) - )) - is_encrypted = data.get(self.const.PAYLOAD_DATA.EE_IS_ENCRYPTED, False) - encrypted_data = data.get(self.const.PAYLOAD_DATA.EE_ENCRYPTED_DATA, None) - if is_encrypted and encrypted_data is not None: - self.P("Received encrypted data. Decrypting...") - str_decrypted_data = self.bc.decrypt_str( - str_b64data=encrypted_data, - str_sender=sender, - ) - decrypted_data = self.json_loads(str_decrypted_data) - if decrypted_data is not None: - received_pipelines = decrypted_data.get("EE_PIPELINES", []) - self.P("Decrypted data size {} with pipelines:\n{}".format( - len(str_decrypted_data), - self.json_dumps(received_pipelines, indent=2), - )) - else: - self.P("Failed to decrypt data.", color='r') - #endif decrypted_data is not None - else: - self.P("Received unencrypted data.") - if sender in self.__allowed_nodes: - # - self.P(f"Updated last_config_get for node '{sender}'") - #endif signature == "UPDATE_MONITOR_01" - - return - - - def process(self): - payload = None - self.__maybe_send() - self.__maybe_process_received() - return payload - diff --git a/plugins/business/tutorials/net_config_monitor.py b/plugins/business/tutorials/net_config_monitor.py new file mode 100644 index 00000000..341abde5 --- /dev/null +++ b/plugins/business/tutorials/net_config_monitor.py @@ -0,0 +1,259 @@ +""" +{ + "NAME" : "peer_config_pipeline", + "TYPE" : "NetworkListener", + + "PATH_FILTER" : [ + null, null, + ["UPDATE_MONITOR_01", "NET_MON_01"], + null + ], + "MESSAGE_FILTER" : {}, + + "PLUGINS" : [ + { + "SIGNATURE" : "NET_CONFIG_MONITOR", + "INSTANCES" : [ + { + "INSTANCE_ID" : "DEFAULT" + } + ] + } + ] +} + +""" +from naeural_core.business.base import BasePluginExecutor as BasePlugin + + +__VER__ = '0.1.0' + +_CONFIG = { + + **BasePlugin.CONFIG, + 'ALLOW_EMPTY_INPUTS' : True, + + 'MAX_INPUTS_QUEUE_SIZE' : 16, + + 'PROCESS_DELAY' : 0, + + 'SEND_EACH' : 10, + + 'REQUEST_CONFIGS_EACH' : 30, + + 'SHOW_EACH' : 60, + + 'DEBUG_NETMON_COUNT' : 2, + + 'VALIDATION_RULES' : { + **BasePlugin.CONFIG['VALIDATION_RULES'], + }, +} + +class NetConfigMonitorPlugin(BasePlugin): + + + def on_init(self): + self.P("Network peer config watch demo initializing...") + self.__last_data_time = 0 + self.__new_nodes_this_iter = 0 + self.__last_shown = 0 + self.__allowed_nodes = {} # contains addresses with no prefixes + self.__debug_netmon_count = self.cfg_debug_netmon_count + return + + + def __get_active_nodes(self, netmon_current_network : dict) -> dict: + """ + Returns a dictionary with the active nodes in the network. + """ + active_network = { + v['address']: v + for k, v in netmon_current_network.items() + if v.get("working", False) == self.const.DEVICE_STATUS_ONLINE + } + return active_network + + def __get_active_nodes_summary_with_peers(self, netmon_current_network: dict): + """ + Looks in all whitelists and finds the nodes that is allowed by most other nodes. + + """ + node_coverage = {} + + active_network = self.__get_active_nodes(netmon_current_network) + + for addr in active_network: + node_coverage[addr] = 0 + #endfor initialize node_coverage + + whitelists = [x.get("whitelist", []) for x in active_network.values()] + for whitelist in whitelists: + for ee_addr in whitelist: + if ee_addr not in active_network: + continue # this address is not active in the network so we skip it + if ee_addr not in node_coverage: + node_coverage[ee_addr] = 0 + node_coverage[ee_addr] += 1 + coverage_list = [(k, v) for k, v in node_coverage.items()] + coverage_list = sorted(coverage_list, key=lambda x: x[1], reverse=True) + + result = self.OrderedDict() + my_addr = self.bc.maybe_remove_prefix(self.ee_addr) + + for i, (ee_addr, coverage) in enumerate(coverage_list): + is_online = active_network.get(ee_addr, {}).get("working", False) == self.const.DEVICE_STATUS_ONLINE + result[ee_addr] = { + "peers" : coverage, + "eeid" : active_network.get(ee_addr, {}).get("eeid", "UNKNOWN"), + 'ver' : active_network.get(ee_addr, {}).get("version", "UNKNOWN"), + 'is_supervisor' : active_network.get(ee_addr, {}).get("is_supervisor", False), + 'allows_me' : my_addr in active_network.get(ee_addr, {}).get("whitelist", []), + 'online' : is_online, + 'whitelist' : active_network.get(ee_addr, {}).get("whitelist", []), + } + return result + + + def __maybe_review_known(self): + if ((self.time() - self.__last_shown) < self.cfg_show_each) or (len(self.__allowed_nodes) == 0): + return + self.__last_shown = self.time() + msg = "Known nodes: " + for addr in self.__allowed_nodes: + eeid = self.netmon.network_node_eeid(addr) + pipelines = self.__allowed_nodes[addr].get("pipelines", []) + names = [p.get("NAME", "NONAME") for p in pipelines] + msg += f"\n - '{eeid}' <{addr}> has {len(pipelines)} pipelines: {names}" + #endfor __allowed_nodes + self.P(msg) + return + + + def __maybe_send(self): + if self.time() - self.__last_data_time > self.cfg_send_each: + self.__last_data_time = self.time() + if len(self.__allowed_nodes) == 0: + self.P("No allowed nodes to send requests to. Waiting for network data...") + else: + self.P("Initiating pipeline requests to allowed nodes...") + to_send = [] + for node_addr in self.__allowed_nodes: + last_request = self.__allowed_nodes[node_addr].get("last_config_get", 0) + if (self.time() - last_request) > self.cfg_request_configs_each: + to_send.append(node_addr) + #endif enough time since last request of this node + #endfor __allowed_nodes + if len(to_send) == 0: + self.P("No nodes need update.") + else: + self.P(f"Local {len(self.local_pipelines)} pipelines. Sending requests to {len(to_send)} nodes...") + # now send some requests + for node_addr in to_send: + node_ee_id = self.netmon.network_node_eeid(node_addr) + self.P(f"Sending GET_PIPELINES to '{node_ee_id}' <{node_addr}>...") + self.cmdapi_send_instance_command( + pipeline="admin_pipeline", + signature="UPDATE_MONITOR_01", + instance_id="UPDATE_MONITOR_01_INST", + instance_command={ "COMMAND": "GET_PIPELINES" }, + node_address=node_addr, + ) + self.__allowed_nodes[node_addr]["last_config_get"] = self.time() + #endfor to_send + #endif len(to_send) == 0 + #endif have allowed nodes + #endif time to send + return + + + def __maybe_process_received(self): + data = self.dataapi_struct_data() + if data is not None: + payload_path = data.get(self.const.PAYLOAD_DATA.EE_PAYLOAD_PATH, [None, None, None, None]) + eeid = payload_path[0] + signature = payload_path[2] + sender = data.get(self.const.PAYLOAD_DATA.EE_SENDER, None) + is_encrypted = data.get(self.const.PAYLOAD_DATA.EE_IS_ENCRYPTED, False) + self.P("Received {}'{}' data from {}".format( + "ENC " if is_encrypted else "", + signature, f"'{eeid}' <{sender}>" if sender != self.ee_addr else "SELF", + )) + if sender == self.ee_addr: + return + if signature == "NET_MON_01": + current_network = data.get("CURRENT_NETWORK", {}) + if len(current_network) == 0: + self.P("Received NET_MON_01 data without CURRENT_NETWORK data.", color='r ') + else: + self.__new_nodes_this_iter = 0 + peers_status = self.__get_active_nodes_summary_with_peers(current_network) + if self.__debug_netmon_count > 0: + # self.P(f"NetMon debug:\n{self.json_dumps(self.__get_active_nodes(current_network), indent=2)}") + self.P(f"Peers status:\n{self.json_dumps(peers_status, indent=2)}") + self.__debug_netmon_count -= 1 + for addr in peers_status: + if addr == self.ee_addr: + # its us, no need to check whitelist + continue + if peers_status[addr]["allows_me"]: + # we have found a whitelist that contains our address + if addr not in self.__allowed_nodes: + self.__allowed_nodes[addr] = { + "whitelist" : peers_status[addr]["whitelist"], + "last_config_get" : 0 + } + self.__new_nodes_this_iter += 1 + #endif addr not in __allowed_nodes + #endif addr allows me + #endfor each addr in peers_status + if self.__new_nodes_this_iter > 0: + self.P(f"Found {self.__new_nodes_this_iter} new peered nodes.") + #endif nodes_data is not None + #endif signature == "NET_MON_01" + + elif signature == "UPDATE_MONITOR_01": + is_encrypted = data.get(self.const.PAYLOAD_DATA.EE_IS_ENCRYPTED, False) + encrypted_data = data.get(self.const.PAYLOAD_DATA.EE_ENCRYPTED_DATA, None) + if is_encrypted and encrypted_data is not None: + self.P("Received UPDATE_MONITOR_01 encrypted data. Decrypting...") + str_decrypted_data = self.bc.decrypt_str( + str_b64data=encrypted_data, + str_sender=sender, + ) + decrypted_data = self.json_loads(str_decrypted_data) + if decrypted_data is not None: + received_pipelines = decrypted_data.get("EE_PIPELINES", []) + self.P("Decrypted data size {} with {} pipelines:\n{}".format( + len(str_decrypted_data), len(received_pipelines), + self.json_dumps([ + { + k:v for k,v in x.items() + if k in ["NAME", "TYPE", "MODIFIED_BY_ADDR", "LAST_UPDATE_TIME"] + } + for x in received_pipelines], + indent=2), + )) + sender_no_prefix = self.bc.maybe_remove_prefix(sender) + self.__allowed_nodes[sender_no_prefix]["pipelines"] = received_pipelines + # now we can add the pipelines to the netmon cache + else: + self.P("Failed to decrypt data.", color='r') + #endif decrypted_data is not None + else: + self.P("Received unencrypted data.") + if sender in self.__allowed_nodes: + # + self.P(f"Updated last_config_get for node '{sender}'") + #endif signature == "UPDATE_MONITOR_01" + + return + + + def process(self): + payload = None + self.__maybe_send() + self.__maybe_process_received() + self.__maybe_review_known() + return payload + diff --git a/ver.py b/ver.py index fd00c30d..5e3b50da 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.56' +__VER__ = '2.0.57' From 236893aba0b48d008f23f3c5ecba00723e8ebc3f Mon Sep 17 00:00:00 2001 From: Cristi Bleotiu Date: Fri, 15 Nov 2024 05:11:01 +0200 Subject: [PATCH 38/61] feat: telegram conversational bot -conversational bot supports both API and hosted agents -added fastapi llm_api plugin for easier interaction with LLM agents -small fix for banking_assistant -> context was disabled --- .../bank_assistant_ro/banking_assistant.py | 11 +- .../fastapi/naeural_llm_api/llm_api.py | 23 +++ .../telegram_conversational_bot_01.py | 183 ++++++++++++++++-- ver.py | 2 +- 4 files changed, 193 insertions(+), 26 deletions(-) create mode 100644 extensions/business/fastapi/naeural_llm_api/llm_api.py diff --git a/extensions/business/fastapi/bank_assistant_ro/banking_assistant.py b/extensions/business/fastapi/bank_assistant_ro/banking_assistant.py index a2b27e76..fa5e09b2 100644 --- a/extensions/business/fastapi/bank_assistant_ro/banking_assistant.py +++ b/extensions/business/fastapi/bank_assistant_ro/banking_assistant.py @@ -1,5 +1,3 @@ -from urllib3 import request - from extensions.business.fastapi.assistant.naeural_assistant import NaeuralAssistantPlugin as BasePlugin @@ -43,8 +41,11 @@ def compute_request_body_llm(self, request_id, body): to_send : dict - the request body to be sent to the agent's pipeline """ request_dict = super(BankingAssistantPlugin, self).compute_request_body_llm(request_id, body) - context = request_dict['STRUCT_DATA'][0]['system_info'] - request_dict['STRUCT_DATA'][0]['context'] = context - request_dict['STRUCT_DATA'][0]['system_info'] = "Esti un asistent bancar excelent care raspunde concis si vrea sa ajute oamenii." + sys_info = request_dict['STRUCT_DATA'][0]['system_info'] + if len(sys_info) > 0: + new_sys_info = "Esti un asistent bancar excelent la o banca despre care stii urmatoarele: " + sys_info + else: + new_sys_info = "Esti un asistent bancar excelent care raspunde concis si vrea sa ajute oamenii." + request_dict['STRUCT_DATA'][0]['system_info'] = new_sys_info return request_dict diff --git a/extensions/business/fastapi/naeural_llm_api/llm_api.py b/extensions/business/fastapi/naeural_llm_api/llm_api.py new file mode 100644 index 00000000..4419df34 --- /dev/null +++ b/extensions/business/fastapi/naeural_llm_api/llm_api.py @@ -0,0 +1,23 @@ +from extensions.business.fastapi.assistant.naeural_assistant import NaeuralAssistantPlugin as BasePlugin + + +_CONFIG = { + **BasePlugin.CONFIG, + + 'PORT': 5006, + 'ASSETS': 'extensions/business/fastapi/naeural_llm_api', + "JINJA_ARGS": { + # Done in order for this API to not have user interface. + 'html_files': [] + }, + 'VALIDATION_RULES': { + **BasePlugin.CONFIG['VALIDATION_RULES'], + }, +} + + +class LlmApiPlugin(BasePlugin): + CONFIG = _CONFIG + + + diff --git a/extensions/business/telegram/telegram_conversational_bot_01.py b/extensions/business/telegram/telegram_conversational_bot_01.py index 620df848..1b392ae8 100644 --- a/extensions/business/telegram/telegram_conversational_bot_01.py +++ b/extensions/business/telegram/telegram_conversational_bot_01.py @@ -1,3 +1,5 @@ +import requests + from naeural_core.business.base import BasePluginExecutor as BasePlugin from extensions.business.mixins.telegram_mixin import _TelegramChatbotMixin @@ -16,45 +18,186 @@ "SYSTEM_PROMPT" : None, "AGENT_TYPE" : "API", "RAG_SOURCE_URL" : None, - + "TEMPERATURE": 0.7, + "API_MODEL_NAME": "gpt-3.5-turbo", + "RESPONSE_ROUTE_API": None, + "RESPONSE_ROUTE_HOSTED": None, + "REQUEST_URL_API": None, + "REQUEST_URL_HOSTED": None, 'VALIDATION_RULES' : { **BasePlugin.CONFIG['VALIDATION_RULES'], }, } + +DEFAULT_RESPONSE_ROUTE_API = ['choices', 0, 'message', 'content'] +DEFAULT_RESPONSE_ROUTE_HOSTED = ['result', 'text_response'] +OPENAI_CHAT_URL = 'https://api.openai.com/v1/chat/completions' +HOSTED_AGENT_URL = 'https://llm-api.naeural.ai/llm_request' + + class TelegramConversationalBot01Plugin( _TelegramChatbotMixin, BasePlugin, - ): +): CONFIG = _CONFIG - def on_init(self): self.__token = self.cfg_telegram_bot_token self.__bot_name = self.cfg_telegram_bot_name + self.__user_data = {} self.__last_status_check = 0 - if self.__custom_handler is not None: - self.P("Building and running the Telegram bot...") - self.bot_build( - token=self.__token, - bot_name=self.__bot_name, - message_handler=self.bot_msg_handler, - run_threaded=True, - ) - self.bot_run() - self.__failed = False - else: - self.P("Custom reply executor could not be created, bot will not run", color='r') - self.__failed = True - raise ValueError("Custom reply executor could not be created") + self.P("Building and running the Telegram bot...") + self.bot_build( + token=self.__token, + bot_name=self.__bot_name, + message_handler=self.bot_msg_handler, + run_threaded=True, + ) + self.bot_run() + self.__failed = False return - + + def get_api_token(self): + return self.cfg_api_token or self.os_environ.get('EE_OPENAI') + + def get_response_route_api(self): + return self.cfg_response_route_api or DEFAULT_RESPONSE_ROUTE_API + + def get_response_route_hosted(self): + return self.cfg_response_route_hosted or DEFAULT_RESPONSE_ROUTE_HOSTED + + def get_request_url_api(self): + return self.cfg_request_url_api or OPENAI_CHAT_URL + + def get_request_url_hosted(self): + return self.cfg_request_url_hosted or HOSTED_AGENT_URL + + def check_request_error_api(self, response): + return 'error' in response or 'error' in response.get('type', '') + + def check_request_error_hosted(self, response): + return 'result' not in response or 'error' in response['result'] + + def process_url_request(self, data, url, bearer_token=None, error_check_func=None, response_route=None): + result = None + try: + headers = { + 'Content-Type': 'application/json', + } + if bearer_token is not None: + headers['Authorization'] = f'Bearer {bearer_token}' + # endif bearer_token provided + response = requests.post( + url, + headers=headers, + json=data, + ) + json_data = response.json() + if error_check_func is not None and error_check_func(json_data): + self.P(f"URL request failed: {json_data}", color='r') + else: + if response_route is not None: + for key in response_route: + json_data = json_data[key] + # endfor key in response_route + # endif response_route provided + result = json_data + # endif error in result + except Exception as e: + self.P(f"URL request failed: {e}", color='r') + # endtry + return result + + def get_response_api(self, user, message): + user_data = self.__user_data[user] + # Retrieve the system prompt + sys_info = user_data.get('system_prompt') + messages = [{'role': 'system', 'content': sys_info}] if sys_info is not None else [] + # Add the previous messages, if any + messages += user_data.get('messages', []) + # Add the current user message + messages.append({'role': 'user', 'content': message}) + + data = { + 'model': self.cfg_api_model_name, + 'temperature': self.cfg_temperature, + 'messages': messages + } + return self.process_url_request( + data, url=self.get_request_url_api(), bearer_token=self.get_api_token(), + error_check_func=self.check_request_error_api, + response_route=self.get_response_route_api() + ) + + def __messages_to_history(self, messages): + res = [] + current_turn = {} + for msg in messages: + if msg['role'] == 'user': + current_turn['request'] = msg['content'] + elif msg['role'] == 'assistant': + current_turn['response'] = msg['content'] + res.append(current_turn) + current_turn = {} + # endif role + # endfor + return res + + def get_response_hosted(self, user, message): + user_data = self.__user_data[user] + # Retrieve the system prompt + sys_info = user_data.get('system_prompt') + # Convert previous messages to history, if any + history = self.__messages_to_history(user_data.get('messages', [])) + data = { + 'request': message, + 'history': history, + 'identity': sys_info + } + return self.process_url_request( + data, url=self.get_request_url_hosted(), error_check_func=self.check_request_error_hosted, + response_route=self.get_response_route_hosted() + ) + + + def get_response(self, user, message): + agent_type = str(self.cfg_agent_type).lower() + self.P(f"Agent type: {agent_type}") + if agent_type == 'api': + return self.get_response_api(user, message) + elif agent_type == 'hosted': + return self.get_response_hosted(user, message) + return None + + def add_conversation_history(self, user, role, content): + self.__user_data[user]['messages'].append({"role": role, "content": content}) + return + + def maybe_init_user_data(self, user): + if user not in self.__user_data: + self.__user_data[user] = { + 'system_prompt': self.cfg_system_prompt or '', + 'messages': [] + } + # endif user not in self.__user_data + return + def bot_msg_handler(self, message, user, **kwargs): - result = message - # here the request-wait-response logic + self.maybe_init_user_data(user) + self.P(f"Received message from {user}: {message}") + response = self.get_response(user, message) + if response is None: + result = "An error occurred while processing the request." + else: + result = response + self.add_conversation_history(user, role='user', content=message) + self.add_conversation_history(user, role='assistant', content=response) + # endif response is not None + self.P(f"Sending response to {user}: {result}") return result diff --git a/ver.py b/ver.py index 5e3b50da..59373067 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.57' +__VER__ = '2.0.58' From 6e729d65e1ab6d1e49903352c48862383cae1285 Mon Sep 17 00:00:00 2001 From: Stefan Saraev Date: Fri, 15 Nov 2024 11:47:09 +0200 Subject: [PATCH 39/61] feat(oracle_sync): add oracle sync plugin - it is responsible to syncing the availability tables computed by oracles at each epoch - defined as a state machine to be easy to understand, model and test --- .../business/oracle_sync/oracle_sync_01.py | 1205 +++++++++++++++++ .../oracle_sync/oracle_sync_test_01.py | 202 +++ ver.py | 2 +- 3 files changed, 1408 insertions(+), 1 deletion(-) create mode 100644 extensions/business/oracle_sync/oracle_sync_01.py create mode 100644 extensions/business/oracle_sync/oracle_sync_test_01.py diff --git a/extensions/business/oracle_sync/oracle_sync_01.py b/extensions/business/oracle_sync/oracle_sync_01.py new file mode 100644 index 00000000..9d6fef57 --- /dev/null +++ b/extensions/business/oracle_sync/oracle_sync_01.py @@ -0,0 +1,1205 @@ +""" +This plugin is used to synchronize the availability tables between the oracles. +Initially thought as a way to synchronize the last availability table, it was +extended to synchronize the availability tables for all epochs. + +This plugin works with a state machine, to better separate the different stages of the sync process. +It works as follows: + +On connection, the plugin requests the availability tables for its missing epochs from the online oracles. +Then, in a loop +0. Wait for the epoch to change +1. Compute the local table of availability + - if the node cannot participate in the sync process, it will request the availability table from the other oracles + - otherwise, it will continue to the next stage +2. Exchange the local table of availability between oracles +3. Compute the median table of availability, based on the local tables received from the oracles + - for each node in the table, compute the median value and sign it +4. Exchange the median table of availability between oracles +5. Compute the agreed median table of availability, based on the median tables received from the oracles + - for each node in the table, compute the most frequent median value and collect the signatures +6. Exchange the agreed median table of availability between oracles +7. Update the epoch manager with the agreed median table +Jump to 0 + +Pipeline config: +{ + "NAME": "oracle_sync", + "PLUGINS": [ + { + "INSTANCES": [ + { + "INSTANCE_ID": "default", + } + ], + "SIGNATURE": "ORACLE_SYNC_01" + } + ], + "TYPE": "NetworkListener", + "PATH_FILTER" : [None, None, "ORACLE_SYNC_01", None], + "MESSAGE_FILTER" : {}, +} +""" + +from naeural_core.business.base import BasePluginExecutor as BaseClass + +_CONFIG = { + **BaseClass.CONFIG, + + # Jobs should have a bigger inputs queue size, because they have to process everything + 'MAX_INPUTS_QUEUE_SIZE': 500, + + # Allow empty inputs in order to send pings from time to time + 'ALLOW_EMPTY_INPUTS': True, + 'PROCESS_DELAY': 0, + + 'SEND_PERIOD': 10, # seconds + 'SEND_INTERVAL': 5, # seconds + + 'EPOCH_START_SYNC': 0, + + 'VALIDATION_RULES': { + **BaseClass.CONFIG['VALIDATION_RULES'], + }, +} + +__VER__ = '0.1.0' + + +class OracleSync01Plugin(BaseClass): + + class STATES: + S0_WAIT_FOR_EPOCH_CHANGE = 'WAIT_FOR_EPOCH_CHANGE' + S1_COMPUTE_LOCAL_TABLE = 'COMPUTE_LOCAL_TABLE' + S2_SEND_LOCAL_TABLE = 'SEND_LOCAL_TABLE' + S3_COMPUTE_MEDIAN_TABLE = 'COMPUTE_MEDIAN_TABLE' + S4_SEND_MEDIAN_TABLE = 'SEND_MEDIAN_TABLE' + S5_COMPUTE_AGREED_MEDIAN_TABLE = 'COMPUTE_AGREED_MEDIAN_TABLE' + S6_SEND_AGREED_MEDIAN_TABLE = 'SEND_AGREED_MEDIAN_TABLE' + S7_UPDATE_EPOCH_MANAGER = 'UPDATE_EPOCH_MANAGER' + S8_SEND_REQUEST_AGREED_MEDIAN_TABLE = 'SEND_REQUEST_AGREED_MEDIAN_TABLE' + S9_COMPUTE_REQUESTED_AGREED_MEDIAN_TABLE = 'COMPUTE_REQUESTED_AGREED_MEDIAN_TABLE' + + def on_init(self): + self.__reset_to_initial_state() + + # All oracles start in the state S7_WAIT_FOR_ORACLE_SYNC + # because they have to wait to receive the agreed median table from the previous epoch + self.state_machine_name = 'OracleSyncPlugin' + self.state_machine_api_init( + name=self.state_machine_name, + state_machine_transitions=self._prepare_job_state_transition_map(), + initial_state=self.STATES.S8_SEND_REQUEST_AGREED_MEDIAN_TABLE, + on_successful_step_callback=self.state_machine_api_callback_do_nothing, + ) + return + + # State machine callbacks + if True: + def _prepare_job_state_transition_map(self): + job_state_transition_map = { + self.STATES.S0_WAIT_FOR_EPOCH_CHANGE: { + 'STATE_CALLBACK': self.__receive_requests_from_oracles_and_send_responses, + 'DESCRIPTION': "Wait for the epoch to change", + 'TRANSITIONS': [ + { + 'NEXT_STATE': self.STATES.S1_COMPUTE_LOCAL_TABLE, + 'TRANSITION_CONDITION': self.__epoch_finished, + 'ON_TRANSITION_CALLBACK': self.state_machine_api_callback_do_nothing, + 'DESCRIPTION': "If the epoch has changed, compute the local table of availability", + }, + ], + }, + self.STATES.S1_COMPUTE_LOCAL_TABLE: { + 'STATE_CALLBACK': self.__compute_local_table, + 'DESCRIPTION': "Compute the local table of availability", + 'TRANSITIONS': [ + { + 'NEXT_STATE': self.STATES.S2_SEND_LOCAL_TABLE, + 'TRANSITION_CONDITION': self.__can_participate_in_sync, + 'ON_TRANSITION_CALLBACK': self.state_machine_api_callback_do_nothing, + 'DESCRIPTION': "If the node can participate, join the sync process", + }, + { + 'NEXT_STATE': self.STATES.S8_SEND_REQUEST_AGREED_MEDIAN_TABLE, + 'TRANSITION_CONDITION': self.__cannot_participate_in_sync, + 'ON_TRANSITION_CALLBACK': self.state_machine_api_callback_do_nothing, + 'DESCRIPTION': "If the node cannot participate, periodically request the agreed median table from the oracles", + } + ], + }, + self.STATES.S2_SEND_LOCAL_TABLE: { + 'STATE_CALLBACK': self.__receive_local_table_and_maybe_send_local_table, + 'DESCRIPTION': "Exchange local table of availability between oracles", + 'TRANSITIONS': [ + { + 'NEXT_STATE': self.STATES.S3_COMPUTE_MEDIAN_TABLE, + 'TRANSITION_CONDITION': self.__send_local_table_timeout, + 'ON_TRANSITION_CALLBACK': self.state_machine_api_callback_do_nothing, + 'DESCRIPTION': "After the exchange phase time expires, compute the median table", + } + ], + }, + self.STATES.S3_COMPUTE_MEDIAN_TABLE: { + 'STATE_CALLBACK': self.__compute_median_table, + 'DESCRIPTION': "Compute the median table of availability, based on the local tables received from the oracles", + 'TRANSITIONS': [ + { + 'NEXT_STATE': self.STATES.S4_SEND_MEDIAN_TABLE, + 'TRANSITION_CONDITION': self.state_machine_api_callback_always_true, + 'ON_TRANSITION_CALLBACK': self.state_machine_api_callback_do_nothing, + 'DESCRIPTION': "Begin the exchange process of the median tables between oracles", + } + ], + }, + self.STATES.S4_SEND_MEDIAN_TABLE: { + 'STATE_CALLBACK': self.__receive_median_table_and_maybe_send_median_table, + 'DESCRIPTION': "Exchange median table of availability between oracles", + 'TRANSITIONS': [ + { + 'NEXT_STATE': self.STATES.S5_COMPUTE_AGREED_MEDIAN_TABLE, + 'TRANSITION_CONDITION': self.__send_median_table_timeout, + 'ON_TRANSITION_CALLBACK': self.state_machine_api_callback_do_nothing, + 'DESCRIPTION': "After the exchange phase time expires, compute the agreed median table", + }, + ], + }, + self.STATES.S5_COMPUTE_AGREED_MEDIAN_TABLE: { + 'STATE_CALLBACK': self.__compute_agreed_median_table, + 'DESCRIPTION': "Compute the agreed median table of availability, based on the median tables received from the oracles", + 'TRANSITIONS': [ + { + 'NEXT_STATE': self.STATES.S6_SEND_AGREED_MEDIAN_TABLE, + 'TRANSITION_CONDITION': self.state_machine_api_callback_always_true, + 'ON_TRANSITION_CALLBACK': self.state_machine_api_callback_do_nothing, + 'DESCRIPTION': "Begin the exchange process of the agreed median tables between oracles", + } + ], + }, + self.STATES.S6_SEND_AGREED_MEDIAN_TABLE: { + 'STATE_CALLBACK': self.__receive_agreed_median_table_and_maybe_send_agreed_median_table, + 'DESCRIPTION': "Exchange agreed median table of availability between oracles", + 'TRANSITIONS': [ + { + 'NEXT_STATE': self.STATES.S7_UPDATE_EPOCH_MANAGER, + 'TRANSITION_CONDITION': self.__send_agreed_value_timeout, + 'ON_TRANSITION_CALLBACK': self.__reset_to_initial_state, + 'DESCRIPTION': "After the exchange phase time expires, update the epoch manager with the agreed median table", + } + ], + }, + self.STATES.S7_UPDATE_EPOCH_MANAGER: { + 'STATE_CALLBACK': self.__update_epoch_manager_with_agreed_median_table, + 'DESCRIPTION': "Update the epoch manager with the agreed median table", + 'TRANSITIONS': [ + { + 'NEXT_STATE': self.STATES.S0_WAIT_FOR_EPOCH_CHANGE, + 'TRANSITION_CONDITION': self.state_machine_api_callback_always_true, + 'ON_TRANSITION_CALLBACK': self.state_machine_api_callback_do_nothing, + 'DESCRIPTION': "Wait for the epoch to change to start a new sync process", + } + ], + }, + self.STATES.S8_SEND_REQUEST_AGREED_MEDIAN_TABLE: { + 'STATE_CALLBACK': self.__receive_agreed_median_table_and_maybe_request_agreed_median_table, + 'DESCRIPTION': "Wait for the oracles to send the agreed median table and periodically request the agreed median table from the oracles", + 'TRANSITIONS': [ + { + 'NEXT_STATE': self.STATES.S9_COMPUTE_REQUESTED_AGREED_MEDIAN_TABLE, + 'TRANSITION_CONDITION': self.__send_request_agreed_median_table_timeout, + 'ON_TRANSITION_CALLBACK': self.state_machine_api_callback_do_nothing, + 'DESCRIPTION': "After the request phase time expires, compute the agreed median table from the received tables", + }, + { + 'NEXT_STATE': self.STATES.S0_WAIT_FOR_EPOCH_CHANGE, + 'TRANSITION_CONDITION': self.__last_epoch_synced_is_previous_epoch, + 'ON_TRANSITION_CALLBACK': self.__reset_to_initial_state, + 'DESCRIPTION': "If the last epoch synced is the previous epoch, start a new sync process", + } + ], + }, + self.STATES.S9_COMPUTE_REQUESTED_AGREED_MEDIAN_TABLE: { + 'STATE_CALLBACK': self.__compute_requested_agreed_median_table, + 'DESCRIPTION': "Compute the agreed median table of availability, based on the received tables", + 'TRANSITIONS': [ + { + 'NEXT_STATE': self.STATES.S0_WAIT_FOR_EPOCH_CHANGE, + 'TRANSITION_CONDITION': self.state_machine_api_callback_always_true, + 'ON_TRANSITION_CALLBACK': self.__reset_to_initial_state, + 'DESCRIPTION': "Begin the exchange process of the agreed median tables between oracles", + } + ], + }, + } + return job_state_transition_map + + def __reset_to_initial_state(self): + """ + Reset the plugin to the initial state. + """ + self.__current_epoch = self.netmon.epoch_manager.get_current_epoch() + self.current_epoch_computed = False + + self.should_expect_to_participate = {} + + self.local_table = None + self.dct_local_tables = {} + self.first_time_local_table_sent = None + self.last_time_local_table_sent = None + + self.median_table = None + self.dct_median_tables = {} + self.first_time_median_table_sent = None + self.last_time_median_table_sent = None + + self.agreed_median_table = {} + self.first_time_agreed_median_table_sent = None + self.last_time_agreed_median_table_sent = None + + self.__last_epoch_synced = 0 # TODO: change the initial value + self.first_time_request_agreed_median_table_sent = None + self.last_time_request_agreed_median_table_sent = None + return + + # S0_WAIT_FOR_EPOCH_CHANGE + def __send_epoch__agreed_median_table(self, start_epoch, end_epoch): + dct_epoch__agreed_median_table = {} + for epoch in range(start_epoch, end_epoch + 1): + dct_epoch__agreed_median_table[epoch] = self.netmon.epoch_manager.get_epoch_availability(epoch) + # end for + + self.add_payload_by_fields( + epoch__agreed_median_table=dct_epoch__agreed_median_table, + ) + return + + def __receive_requests_from_oracles_and_send_responses(self): + """ + Receive requests from the oracles and send responses. + """ + for dct_message in self.get_received_messages_from_oracles(): + sender = dct_message.get(self.ct.PAYLOAD_DATA.EE_SENDER) + oracle_data = dct_message.get('ORACLE_DATA') + stage = oracle_data.get('STAGE') + request_agreed_median_table = oracle_data.get('REQUEST_AGREED_MEDIAN_TABLE') + start_epoch = oracle_data.get('START_EPOCH') + end_epoch = oracle_data.get('END_EPOCH') + + if request_agreed_median_table: + self.P(f"Received request from oracle {sender}: {stage = }, {start_epoch = }, {end_epoch = }") + self.__send_epoch__agreed_median_table(start_epoch, end_epoch) + # end for + + return + + def __epoch_finished(self): + """ + Check if the epoch has changed. + + Returns + ------- + bool : True if the epoch has changed, False otherwise + """ + return self.__current_epoch != self.netmon.epoch_manager.get_current_epoch() + + # S1_COMPUTE_LOCAL_TABLE + def __compute_local_table(self): + """ + Compute the local table for the current node. + If the node is not a supervisor, the local table will be empty. + """ + # if current node is not supervisor, just return + if not self.__is_supervisor(self.node_addr): + self.P("I am not a supervisor. I will not participate in the sync process") + self.local_table = {} + return + + # node is supervisor, compute local table + self.local_table = { + node: self.netmon.epoch_manager.get_node_previous_epoch(node) + for node in self.netmon.all_nodes + } + + # if self is not full online, it should not participate in the sync process + if not self.__was_full_online(self.node_addr): + self.P("I was not full online. I will not participate in the sync process") + return + + # if self is full online, it should participate in the sync process + # mark oracles that were seen full online in the previous epoch as True + for oracle in self.__get_oracle_list(): + self.should_expect_to_participate[oracle] = self.__was_potentially_full_online(oracle) + + self.P(f"Computed local table {self.local_table}") + return + + def __can_participate_in_sync(self): + """ + Check if the current node can participate in the sync process. + A node can participate if it is a supervisor and was full online in the previous epoch. + + Returns + ------- + bool : True if the node can participate in the sync process, False otherwise + """ + return self.__is_supervisor(self.node_addr) and self.__was_full_online(self.node_addr) + + def __cannot_participate_in_sync(self): + """ + Check if the current node cannot participate in the sync process. + A node can participate if it is a supervisor and was full online in the previous epoch. + + Returns + ------- + bool : True if the node cannot participate in the sync process, False otherwise + """ + return not self.__can_participate_in_sync() + + # S2_SEND_LOCAL_TABLE + def __receive_local_table_and_maybe_send_local_table(self): + """ + Receive the local table from the oracles and + send the local table to the oracles each `self.cfg_send_interval` seconds. + """ + # Receive values from oracles + for dct_message in self.get_received_messages_from_oracles(): + sender = dct_message.get(self.ct.PAYLOAD_DATA.EE_SENDER) + oracle_data = dct_message.get('ORACLE_DATA') + stage = oracle_data.get('STAGE') + local_table = oracle_data.get('LOCAL_TABLE') + + if not self.__check_received_local_table_ok(sender, oracle_data): + continue + + self.P(f"Received message from oracle {sender}: {stage = }, {local_table = }") + self.dct_local_tables[sender] = local_table + # end for + + # Send value to oracles + if self.first_time_local_table_sent is None: + self.first_time_local_table_sent = self.time() + + if self.last_time_local_table_sent is not None and self.time() - self.last_time_local_table_sent < self.cfg_send_interval: + return + + self.P(f"Sending {self.local_table=}") + + oracle_data = { + 'LOCAL_TABLE': self.local_table, + 'STAGE': self.__get_current_state() + } + self.bc.sign(oracle_data, add_data=True, use_digest=True) + + self.add_payload_by_fields(oracle_data=oracle_data) + self.last_time_local_table_sent = self.time() + return + + def __send_local_table_timeout(self): + """ + Check if the exchange phase of the local table has finished. + + Returns + ------- + bool: True if the exchange phase of the local table has finished, False otherwise + """ + return self.time() - self.first_time_local_table_sent > self.cfg_send_period + + # S3_COMPUTE_MEDIAN_TABLE + def __compute_median_table(self): + """ + Compute the median table from the local tables received from the oracles. + For each node that was seen in the local tables, compute the median value and sign it. + """ + # should not have received any None values + valid_local_tables = [x for x in self.dct_local_tables.values() if x is not None] + valid_local_tables_count = len(valid_local_tables) + + if valid_local_tables_count <= self.__count_half_of_valid_oracles(): + self.median_table = None + self.P("Could not compute median. Too few valid values", color='r') + return + + # compute median for each node in list + self.median_table = {} + + all_nodes_in_local_tables = set().union(*(set(value_table.keys()) for value_table in valid_local_tables)) + for node in all_nodes_in_local_tables: + # default value 0 because if node not in value_table, it means it was not seen + all_node_local_table_values = (value_table.get(node, 0) for value_table in valid_local_tables) + valid_node_local_table_values = list(x for x in all_node_local_table_values if x is not None) + + # compute median and sign -- signature will be used in the next step + self.median_table[node] = {'VALUE': round(self.np.median(valid_node_local_table_values))} + self.bc.sign(self.median_table[node], add_data=True, use_digest=True) +# end for + + self.P(f"Computed median table {self.__compute_simple_median_table(self.median_table)}") + return + + # S4_SEND_MEDIAN_TABLE + def __receive_median_table_and_maybe_send_median_table(self): + """ + Receive the median table from the oracles and + send the median table to the oracles each `self.cfg_send_interval` seconds. + """ + # Receive medians from oracles + for dct_message in self.get_received_messages_from_oracles(): + sender = dct_message.get(self.ct.PAYLOAD_DATA.EE_SENDER) + oracle_data = dct_message.get('ORACLE_DATA') + stage = oracle_data.get('STAGE') + median_table = oracle_data.get('MEDIAN_TABLE') + + if not self.__check_received_median_table_ok(sender, oracle_data): + continue + + simple_median = self.__compute_simple_median_table(self.median_table) + self.P(f"Received message from oracle {sender}: {stage = }, {simple_median = }") + + self.dct_median_tables[sender] = median_table + # end for + + # Send median to oracles + if self.first_time_median_table_sent is None: + self.first_time_median_table_sent = self.time() + + if self.last_time_median_table_sent is not None and self.time() - self.last_time_median_table_sent < self.cfg_send_interval: + return + + self.P(f"Sending median {self.__compute_simple_median_table(self.median_table)}") + oracle_data = { + 'STAGE': self.__get_current_state(), + 'MEDIAN_TABLE': self.median_table, + } + self.bc.sign(oracle_data, add_data=True, use_digest=True) + + self.add_payload_by_fields(oracle_data=oracle_data) + self.last_time_median_table_sent = self.time() + return + + def __send_median_table_timeout(self): + """ + Check if the exchange phase of the median table has finished. + + Returns + ------- + bool: True if the exchange phase of the median table has finished, False otherwise + """ + return self.time() - self.first_time_median_table_sent > self.cfg_send_period + + # S5_COMPUTE_AGREED_MEDIAN_TABLE + def __compute_agreed_median_table(self): + """ + Compute the agreed median table from the median tables received from the oracles. + For each node that was seen in the median tables, compute the most frequent median value. + """ + # im expecting all median tables to contain all nodes + # but some errors can occur, so this does no harm + all_nodes = set().union(*(set(value_table.keys()) for value_table in self.dct_median_tables.values())) + + # keep in a dictionary a list with all median values for each node + dct_node_median_tables = {} + for node in all_nodes: + dct_node_median_tables[node] = [ + median_table[node] + for median_table in self.dct_median_tables.values() + if node in median_table + ] + # end for node + + # compute the frequency of each median value for each node + for node in all_nodes: + dct_median_frequentcy = {} + for median in (dct_median['VALUE'] for dct_median in dct_node_median_tables[node]): + if median not in dct_median_frequentcy: + dct_median_frequentcy[median] = 0 + dct_median_frequentcy[median] += 1 + # end for median + + max_count = max(dct_median_frequentcy.values()) + most_frequent_median = next(k for k, v in dct_median_frequentcy.items() if v == max_count) + + # get all median table values that have the most frequent median + # we do this because in the median table we find both the value and the signature + lst_dct_freq_median = [ + dct_median + for dct_median in dct_node_median_tables[node] + if dct_median['VALUE'] == most_frequent_median + ] + + if len(lst_dct_freq_median) > self.__count_half_of_valid_oracles(): + self.P(f"Computed agreed median table for node {node}: {most_frequent_median}. " + f"Dct freq {dct_median_frequentcy}") + self.agreed_median_table[node] = { + 'VALUE': most_frequent_median, + 'SIGNATURES': lst_dct_freq_median, + } + else: + self.P(f"Failed to compute agreed median table for node {node}. " + f"Could not achieve consensus. Dct freq:\n{self.json_dumps(dct_median_frequentcy, indent=2)}\n" + f"{self.json_dumps(self.dct_median_tables, indent=2)}", color='r') + # this is a situation without recovery -- it can happen if the network is attacked + # either the node is malicious or some oracles are malicious + raise Exception("Failed to compute agreed median table") + self.agreed_median_table[node] = { + 'VALUE': 0, + 'SIGNATURES': [], + } + # end for + + if len(self.agreed_median_table) == 0: + self.P("Failed to compute agreed median table. Not enough online oracles", color='r') + + self.current_epoch_computed = True + return + + # S6_SEND_AGREED_MEDIAN_TABLE + def __receive_agreed_median_table_and_maybe_send_agreed_median_table(self): + """ + Receive the agreed median table from the oracles and + send the agreed median table to the oracles each `self.cfg_send_interval` seconds. + """ + # Receive agreed values from oracles + for dct_message in self.get_received_messages_from_oracles(): + sender = dct_message.get(self.ct.PAYLOAD_DATA.EE_SENDER) + oracle_data = dct_message.get('ORACLE_DATA') + stage = oracle_data.get('STAGE') + agreed_median_table = oracle_data.get('AGREED_MEDIAN_TABLE') + + if not self.__check_received_agreed_median_table_ok(sender, oracle_data): + continue + + simple_agreed_median_table = self.__compute_simple_agreed_value_table(agreed_median_table) + self.P(f"Received message from oracle {sender}: {stage = }, {simple_agreed_median_table = }") + # end for + + # Send agreed value to oracles + if self.first_time_agreed_median_table_sent is None: + self.first_time_agreed_median_table_sent = self.time() + + if self.last_time_agreed_median_table_sent is not None and self.time() - self.last_time_agreed_median_table_sent < self.cfg_send_interval: + return + + oracle_data = { + 'STAGE': self.__get_current_state(), + 'AGREED_MEDIAN_TABLE': self.agreed_median_table, + } + + self.P(f"Sending median table {self.__compute_simple_agreed_value_table(self.agreed_median_table)}") + self.add_payload_by_fields(oracle_data=oracle_data) + self.last_time_agreed_median_table_sent = self.time() + return + + def __send_agreed_value_timeout(self): + """ + Check if the exchange phase of the agreed median table has finished. + + Returns + ------- + bool: True if the exchange phase of the agreed median table has finished, False otherwise + """ + return self.time() - self.first_time_agreed_median_table_sent > self.cfg_send_period + + # S7_UPDATE_EPOCH_MANAGER + def __update_epoch_manager_with_agreed_median_table(self, epoch=None, agreed_median_table=None): + """ + Update the epoch manager with the agreed median table for the epoch. + If both parameters are None, update the last epoch with `self.agreed_median_table`. + + Otherwise, update the target epoch with the agreed median table. + + Parameters + ---------- + epoch : int, optional + The epoch to update, by default None + agreed_median_table : dict, optional + The agreed median table to add to epoch manager history, by default None + """ + + if epoch is None: + # update previous epoch + epoch = self.netmon.epoch_manager.get_current_epoch() - 1 + # end if + + if agreed_median_table is None: + agreed_median_table = self.agreed_median_table + # end if + + if epoch <= self.__last_epoch_synced: + self.P("Epoch manager history already updated with this epoch", color='r') + return + + if epoch > self.__last_epoch_synced + 1: + self.P(f"Detected a skip in epoch sync algorithm. " + f"Last known epoch synced {self.__last_epoch_synced} " + f"Current epoch {epoch}", color='r') + return + + self.__last_epoch_synced = epoch + + self.netmon.epoch_manager.update_epoch_availability(epoch, agreed_median_table) + return + + # S8_SEND_REQUEST_AGREED_MEDIAN_TABLE + def __receive_agreed_median_table_and_maybe_request_agreed_median_table(self): + """ + Receive the agreed median table from the oracles and + request the agreed median table from the oracles each `self.cfg_send_interval` seconds. + + - if node receives the agreed median table for the last epoch, update the epoch manager + - if node connects at 00:01, receives availability from 2 days ago, transition back to this state, then to s0 + - if node connects at 0X:00, receives availability from prev day, transition back to s0 + """ + + # Receive agreed values from oracles + for dct_message in self.get_received_messages_from_oracles(): + sender = dct_message.get(self.ct.PAYLOAD_DATA.EE_SENDER) + oracle_data = dct_message.get('ORACLE_DATA') + dct_epoch_agreed_median_table = oracle_data.get('EPOCH__AGREED_MEDIAN_TABLE') + + # TODO: check stage ok (from S0) + if not self.__check_received_epoch__agreed_median_table_ok(sender, oracle_data): + continue + + message_invalid = False + for epoch, agreed_median_table in dct_epoch_agreed_median_table.items(): + if not self.__check_agreed_median_table(sender, agreed_median_table): + # if one agreed median table is invalid, ignore the entire message + message_invalid = True + break + # end for epoch agreed table + + if message_invalid: + continue + + # sort dct_epoch_agreed_median_table by epoch in ascending order + received_epochs = sorted(dct_epoch_agreed_median_table.keys()) + self.P(f"Received availability table for epochs {received_epochs} from {sender = }") + + self.dct_median_tables[sender] = dct_epoch_agreed_median_table + # end for received messages + + # Send request to get agreed value from oracles + if self.first_time_request_agreed_median_table_sent is None: + self.first_time_request_agreed_median_table_sent = self.time() + + if self.last_time_request_agreed_median_table_sent is not None and self.time() - self.last_time_request_agreed_median_table_sent < self.cfg_send_interval: + return + + # Return if no need to sync; the last epoch synced is the previous epoch + if self.__last_epoch_synced_is_previous_epoch(): + self.P("Last epoch synced is the previous epoch. No need to sync") + return + + oracle_data = { + 'STAGE': self.__get_current_state(), + 'REQUEST_AGREED_MEDIAN_TABLE': True, + 'START_EPOCH': self.__last_epoch_synced + 1, + 'END_EPOCH': self.__current_epoch - 1, + } + + self.P("Sending broadcast request for agreed median table for epochs " + f"{self.__last_epoch_synced + 1} to {self.__current_epoch - 1}") + self.add_payload_by_fields(oracle_data=oracle_data) + self.last_time_request_agreed_median_table_sent = self.time() + return + + def __send_request_agreed_median_table_timeout(self): + """ + Check if the exchange phase of the agreed median table has finished. + + Returns + ------- + bool: True if the exchange phase of the agreed median table has finished, False otherwise + """ + # 10 times the normal period because we want to make sure that oracles can respond + timeout_expired = self.time() - self.first_time_request_agreed_median_table_sent > self.cfg_send_period * 10 + + return not self.__last_epoch_synced_is_previous_epoch() and timeout_expired + + def __last_epoch_synced_is_previous_epoch(self): + """ + Check if the agreed median table for the last epoch has been received. + + Returns + ------- + bool: True if the agreed median table for the last epoch has been received, False otherwise + """ + return self.__last_epoch_synced == self.__current_epoch - 1 + + # S9_COMPUTE_REQUESTED_AGREED_MEDIAN_TABLE + def __compute_requested_agreed_median_table(self): + """ + Compute the agreed median table from the received tables. + """ + + """ + self.dct_median_tables = { + 'oracle1': { + epoch1: { + node1: {VALUE: 1, SIGNATURE: 'signature'}, + node2: {VALUE: 2, SIGNATURE: 'signature'}, + }, + epoch2: { + node1: {VALUE: 1, SIGNATURE: 'signature'}, + node2: {VALUE: 2, SIGNATURE: 'signature'}, + }, + }, + 'oracle2': { + epoch1: { + node1: {VALUE: 1, SIGNATURE: 'signature'}, + node2: {VALUE: 2, SIGNATURE: 'signature'}, + }, + epoch2: { + node1: {VALUE: 1, SIGNATURE: 'signature'}, + node2: {VALUE: 2, SIGNATURE: 'signature'}, + }, + }, + } + """ + # self.dct_median_tables contains dict with epoch as key and agreed median table as value + + dct_epoch_lst_agreed_median_table = {} + for _, dct_epoch_agreed_median_table in self.dct_median_tables.items(): + for epoch, agreed_median_table in dct_epoch_agreed_median_table.items(): + if epoch not in dct_epoch_lst_agreed_median_table: + dct_epoch_lst_agreed_median_table[epoch] = [] + dct_epoch_lst_agreed_median_table[epoch].append(agreed_median_table) + # end for epoch agreed table + # end for received messages + + for epoch, lst_agreed_median_table in dct_epoch_lst_agreed_median_table.items(): + # im expecting all median tables to contain all nodes + # but some errors can occur, so this does no harm + all_nodes = set().union(*(set(value_table.keys()) for value_table in lst_agreed_median_table)) + + # keep in a dictionary a list with all median values for each node + dct_node_median_tables = {} + for node in all_nodes: + dct_node_median_tables[node] = [ + median_table[node] + for median_table in lst_agreed_median_table + if node in median_table + ] + # end for node + + # compute the frequency of each median value for each node + epoch__agreed_median_table = {} + + for node in all_nodes: + dct_median_frequentcy = {} + for median in (dct_median['VALUE'] for dct_median in dct_node_median_tables[node]): + if median not in dct_median_frequentcy: + dct_median_frequentcy[median] = 0 + dct_median_frequentcy[median] += 1 + # end for median + + max_count = max(dct_median_frequentcy.values()) + most_frequent_median = next(k for k, v in dct_median_frequentcy.items() if v == max_count) + + # get all median table values that have the most frequent median + # we do this because in the median table we find both the value and the signature + lst_dct_freq_median = [ + dct_median + for dct_median in dct_node_median_tables[node] + if dct_median['VALUE'] == most_frequent_median + ] + + epoch__agreed_median_table[node] = lst_dct_freq_median[0] + # end for node + simple_epoch__agreed_median_table = self.__compute_simple_agreed_value_table(epoch__agreed_median_table) + self.P(f"Computed availability table for {epoch = }, {simple_epoch__agreed_median_table = }") + self.__update_epoch_manager_with_agreed_median_table(epoch, epoch__agreed_median_table) + # end for epoch + return + + # Utils + if True: + def __is_supervisor(self, node: str): + """ + Check if the node is a supervisor. + + Parameters + ---------- + node : str + The node to check + + Returns + ------- + bool : True if the node is a supervisor, False otherwise + """ + return self.netmon.network_node_is_supervisor(node) + + def __was_full_online(self, node: str): + """ + Check if the node was full online in the previous epoch. + + Parameters + ---------- + node : str + The node to check + + Returns + ------- + bool : True if the node was full online in the previous epoch, False otherwise + """ + return self.local_table.get(node, 0) == 255 + + def __was_potentially_full_online(self, node: str): + """ + Check if the node was potentially full online in the previous epoch. + Potentially full online means that the node was seen with a value of 254 or 255. + We accept 254 because the current node and the target node could have been + offline for < 2 min in different intervals, which means that neither of them + received heartbeats from the other for a period of 4 minutes. + + Parameters + ---------- + node : str + The node to check + + Returns + ------- + bool : True if the node was potentially full online in the previous epoch, False otherwise + """ + return self.local_table.get(node, 0) >= 254 + + def __get_oracle_list(self): + """ + Get the list of oracles. + For now we consider that all supervisors are oracles. + + Returns + ------- + list : The list of oracles + """ + return [node for node in self.netmon.all_nodes if self.__is_supervisor(node)] + + def __get_current_state(self): + """ + Get the current state of the state machine. + + Returns + ------- + str : The current state of the state machine + """ + return self.state_machine_api_get_current_state(self.state_machine_name) + + def __count_half_of_valid_oracles(self): + """ + Count the number of oracles that are expected to participate in the sync process. + + Returns + ------- + int : The number of oracles that are expected to participate in the sync process + """ + return sum(self.should_expect_to_participate.values()) / 2 + + def get_received_messages_from_oracles(self): + """ + Get the messages received from the oracles. + This method returns a generator for memory efficiency. + + Returns + ------- + generator : The messages received from the oracles + """ + dct_messages = self.dataapi_struct_datas() + received_messages = (dct_messages[i] for i in range(len(dct_messages))) + + # we use a DCT that already filters the messages from the oracles + received_messages_from_oracles = received_messages + return received_messages_from_oracles + + def __check_received_local_table_ok(self, sender, oracle_data): + """ + Check if the received value table is ok. Print the error message if not. + + Parameters: + ---------- + sender : str + The sender of the message + oracle_data : dict + The data received from the oracle + + Returns: + ------- + bool : True if the received value table is ok, False otherwise + """ + sentinel = object() + + stage = oracle_data.get('STAGE', sentinel) + signature = oracle_data.get('EE_SIGN', sentinel) + value = oracle_data.get('VALUE', sentinel) + + if stage == sentinel or signature == sentinel or value == sentinel: + self.P(f"Received message from oracle {sender} with missing fields: " + f"{sentinel = }, {stage = }, {signature = }, {value = }", color='r') + return False + + if stage is None or signature is None: + self.P(f"Received message from oracle {sender} with `None` fields: " + f"{stage = }, {signature = }", color='r') + return False + + if stage != self.STATES.S2_SEND_LOCAL_TABLE: + self.P(f"Received message from oracle {sender} with wrong stage: {stage = }", color='r') + return False + + if not self.bc.verify(dct_data=oracle_data, str_signature=None, sender_address=None)['valid']: + self.P(f"Invalid signature from oracle {sender}", color='r') + return False + + if not self.should_expect_to_participate.get(sender, False) and value is not None: + self.P(f"Node {sender} should not have sent value {value}. ignoring...", color='r') + return False + + if self.should_expect_to_participate.get(sender, False) and value is None: + self.P(f"Oracle {sender} should have sent value. ignoring...", color='r') + return False + + return True + + def __check_received_median_table_ok(self, sender, oracle_data): + """ + Check if the received median is ok. Print the error message if not. + + Parameters: + ---------- + sender : str + The sender of the message + oracle_data : dict + The data received from the oracle + + Returns: + ------- + bool : True if the received median is ok, False otherwise + """ + + sentinel = object() + + stage = oracle_data.get('STAGE', sentinel) + signature = oracle_data.get('EE_SIGN', sentinel) + median = oracle_data.get('MEDIAN', sentinel) + + if stage == sentinel or signature == sentinel or median == sentinel: + self.P(f"Received message from oracle {sender} with missing fields: " + f"{sentinel = }, {stage = }, {signature = }, {median = }", color='r') + return False + + if stage is None or signature is None: + self.P(f"Received message from oracle {sender} with `None` fields: " + f"{stage = }, {signature = }", color='r') + return False + + if stage != self.STATES.S4_SEND_MEDIAN_TABLE: + self.P(f"Received message from oracle {sender} with wrong stage: {stage = }", color='r') + return False + + if not self.bc.verify(dct_data=oracle_data, str_signature=None, sender_address=None)['valid']: + self.P(f"Invalid signature from oracle {sender}", color='r') + return False + + # in the should expect to participate dictionary, only oracles that were seen + # as full online are marked as True + if not self.should_expect_to_participate.get(sender, False) and median is not None: + self.P(f"Oracle {sender} should not have sent median {median}. ignoring...", color='r') + return False + + if median is None: + self.P(f"Oracle {sender} could not compute median. ignoring...", color='r') + return False + + return True + + def __check_received_agreed_median_table_ok(self, sender, oracle_data): + """ + Check if the received agreed value is ok. Print the error message if not. + + Parameters: + ---------- + sender : str + The sender of the message + oracle_data : dict + The data received from the oracle + + Returns: + ------- + bool : True if the received agreed value is ok, False otherwise + """ + sentinel = object() + + stage = oracle_data.get('STAGE', sentinel) + agreed_median_table = oracle_data.get('AGREED_MEDIAN_TABLE', sentinel) + + if stage == sentinel or agreed_median_table == sentinel: + self.P(f"Received message from oracle {sender} with missing fields: " + f"{sentinel = }, {stage = }, {agreed_median_table = }", color='r') + return False + + if stage is None or agreed_median_table is None: + self.P(f"Received message from oracle {sender} with `None` fields: " + f"{stage = }, {agreed_median_table = }", color='r') + return False + + if stage != self.STATES.S6_SEND_AGREED_MEDIAN_TABLE: + self.P(f"Received message from oracle {sender} with wrong stage: {stage = }", color='r') + return False + + # in the should expect to participate dictionary, only oracles that were seen + # as full online are marked as True + if not self.should_expect_to_participate.get(sender, False) and agreed_median_table is not None: + self.P(f"Oracle {sender} should not have sent agreed_value_table {agreed_median_table}. ignoring...", color='r') + return False + + if not self.__check_agreed_median_table(sender, agreed_median_table): + return False + + values_signed_ok = all( + dct_node['VALUE'] == self.agreed_median_table[node]['VALUE'] + for node, dct_node in agreed_median_table.items() + ) + if not values_signed_ok: + self.P(f"Invalid agreed value from oracle {sender}", color='r') + return False + + return True + + def __check_received_epoch__agreed_median_table_ok(self, sender, oracle_data): + """ + Check if the received agreed value is ok. Print the error message if not. + This method is used to check if the message received is valid. The checking + of each agreed median table is done in the __check_agreed_median_table method. + + Parameters + ---------- + sender : str + The sender of the message + oracle_data : dict + The data received from the oracle + """ + sentinel = object() + + epoch__agreed_median_table = oracle_data.get('EPOCH__AGREED_MEDIAN_TABLE', sentinel) + + if epoch__agreed_median_table == sentinel: + self.P(f"Received message from oracle {sender} with missing fields: " + f"{sentinel = }, {epoch__agreed_median_table = }", color='r') + return False + + if epoch__agreed_median_table is None: + self.P(f"Received message from oracle {sender} with `None` fields: " + f"{epoch__agreed_median_table = }", color='r') + return False + + return True + + def __check_agreed_median_table(self, sender, agreed_median_table): + """ + Check if the agreed median table is valid. + + Parameters + ---------- + sender : str + The sender of the message + agreed_median_table : dict + The agreed median table received from the oracle + """ + + if agreed_median_table is None: + self.P(f"Received agreed median table from oracle {sender} is None. ignoring...", color='r') + return False + + median_signatures_ok = all( + all( + self.bc.verify(dct_data=signature, str_signature=None, sender_address=None)['valid'] + for signature in dct_node['SIGNATURES'] + ) + for dct_node in agreed_median_table.values() + ) + if not median_signatures_ok: + self.P(f"Invalid signatures from oracle {sender}", color='r') + return False + + values_same = all( + all(dct_node['VALUE'] == signature['VALUE'] for signature in dct_node['SIGNATURES']) + for dct_node in agreed_median_table.values() + ) + if not values_same: + self.P(f"Signatures from oracle {sender} are for values different than the agreed value", color='r') + return False + + return True + + def __compute_simple_median_table(self, median_table): + """ + Compute a simple median table with only the values. + This method is used to print the median table in a more readable format. + + Parameters + ---------- + median_table : dict + The median table to simplify + + Returns + ------- + dict : The simplified median table + """ + if median_table is None: + return None + simple_median_table = {} + for node, dct_node in median_table.items(): + simple_median_table[node] = dct_node['VALUE'] + + return simple_median_table + + def __compute_simple_agreed_value_table(self, agreed_value_table): + """ + Compute a simple agreed value table with only the values. + This method is used to print the agreed value table in a more readable format. + + Parameters + ---------- + agreed_value_table : dict + The agreed value table to simplify + + Returns + ------- + dict : The simplified agreed value table + """ + if agreed_value_table is None: + return None + simple_agreed_value_table = {} + for node, dct_node in agreed_value_table.items(): + simple_agreed_value_table[node] = dct_node['VALUE'] + + return simple_agreed_value_table + + def on_command(self, data, request_agreed_median_table=None, **kwargs): + # should receive command to send the agreed median table for a set of epochs + # this is useful for a new oracle that connects to the network and needs to catch up + # used in state S7_WAIT_FOR_ORACLE_SYNC + + # WARNING! using the new api for commands + if request_agreed_median_table: + start_epoch = data.get("START_EPOCH", None) + end_epoch = data.get("END_EPOCH", None) + if start_epoch is None or end_epoch is None: + self.P(f"Received command without start or end epochs defined. " + f"Interval [{start_epoch}, {end_epoch}]. Ignoring...", color='r') + return + + dct_epoch__agreed_median_table = {} + for epoch in range(start_epoch, end_epoch + 1): + dct_epoch__agreed_median_table[epoch] = self.netmon.epoch_manager.get_epoch_availability(epoch) + # end for + + self.P(f"Received request to send agreed median table for closed epoch interval [{start_epoch}, {end_epoch}]") + self.add_payload_by_fields( + epoch__agreed_median_table=dct_epoch__agreed_median_table, + command_params=data, + ) + return + + def process(self): + self.state_machine_api_step(self.state_machine_name) + return diff --git a/extensions/business/oracle_sync/oracle_sync_test_01.py b/extensions/business/oracle_sync/oracle_sync_test_01.py new file mode 100644 index 00000000..ff07994d --- /dev/null +++ b/extensions/business/oracle_sync/oracle_sync_test_01.py @@ -0,0 +1,202 @@ +""" +This plugin is used to synchronize the availability tables between the oracles. +Initially thought as a way to synchronize the last availability table, it was +extended to synchronize the availability tables for all epochs. + +This plugin works with a state machine, to better separate the different stages of the sync process. +It works as follows: + +On connection, the plugin requests the availability tables for its missing epochs from the online oracles. +Then, in a loop +0. Wait for the epoch to change +1. Compute the local table of availability + - if the node cannot participate in the sync process, it will request the availability table from the other oracles + - otherwise, it will continue to the next stage +2. Exchange the local table of availability between oracles +3. Compute the median table of availability, based on the local tables received from the oracles + - for each node in the table, compute the median value and sign it +4. Exchange the median table of availability between oracles +5. Compute the agreed median table of availability, based on the median tables received from the oracles + - for each node in the table, compute the most frequent median value and collect the signatures +6. Exchange the agreed median table of availability between oracles +7. Update the epoch manager with the agreed median table +Jump to 0 + +Pipeline config: +{ + "NAME": "oracle_sync", + "PLUGINS": [ + { + "INSTANCES": [ + { + "INSTANCE_ID": "default", + } + ], + "SIGNATURE": "ORACLE_SYNC_01" + } + ], + "TYPE": "NetworkListener", + "PATH_FILTER" : [None, None, "ORACLE_SYNC_01", None], + "MESSAGE_FILTER" : {}, +} +""" + +from extensions.business.oracle_sync.oracle_sync_01 import OracleSyncTest01Plugin as BaseClass + +_CONFIG = { + **BaseClass.CONFIG, + + "DUMMY_SENDER": None, + 'LAST_EPOCH_SYNCED': 0, + + 'VALIDATION_RULES': { + **BaseClass.CONFIG['VALIDATION_RULES'], + }, +} + +__VER__ = '0.1.0' + + +class OracleSyncTest01Plugin(BaseClass): + + def _OracleSync01Plugin__reset_to_initial_state(self): + """ + Reset the plugin to the initial state. + """ + self._OracleSync01Plugin__current_epoch = self.netmon.epoch_manager.get_current_epoch() # TODO + self.current_epoch_computed = False + + self.should_expect_to_participate = {} + + self.local_table = None + self.dct_local_tables = {} + self.first_time_local_table_sent = None + self.last_time_local_table_sent = None + + self.median_table = None + self.dct_median_tables = {} + self.first_time_median_table_sent = None + self.last_time_median_table_sent = None + + self.agreed_median_table = {} + self.first_time_agreed_median_table_sent = None + self.last_time_agreed_median_table_sent = None + + self._OracleSync01Plugin__last_epoch_synced = self.cfg_last_epoch_synced + self.first_time_request_agreed_median_table_sent = None + self.last_time_request_agreed_median_table_sent = None + return + + # State machine callbacks + if True: + # S0_WAIT_FOR_EPOCH_CHANGE + def _OracleSync01Plugin__epoch_finished(self): + """ + Check if the epoch has changed. + + Returns + ------- + bool : True if the epoch has changed, False otherwise + """ + return self.__current_epoch != self.netmon.epoch_manager.get_current_epoch() # TODO + + # S1_COMPUTE_LOCAL_TABLE + def _OracleSync01Plugin__compute_local_table(self): + """ + Compute the local table for the current node. + If the node is not a supervisor, the local table will be empty. + """ + self.oracle_list = ['sender0', 'sender1', 'sender2'] + # self.oracle_list = ['sender0'] + self.value_table = { + 'sender0': self.np.random.randint(255, 256), + 'sender1': self.np.random.randint(255, 256), + 'sender2': self.np.random.randint(100, 102), + 'a': self.np.random.randint(100 - 1, 100 + 2), + } + self.P(f"Computed value table {self.value_table}") + + # if self.cfg_sender_dummy == 'sender2' or self.cfg_sender_dummy == 'sender1': + # self.P(f"I, {self.cfg_sender_dummy}, am a bandit. I will try to break the consensus") + # self.value_table = { + # 'sender0': 0, + # 'sender1': 255, + # 'sender2': 255, + # 'a': 0, + # } + + for oracle in self.__get_oracle_list(): + self.should_expect_to_participate[oracle] = self._OracleSync01Plugin__was_potentially_full_online(oracle) + + return + + + def _OracleSync01Plugin__can_participate_in_sync(self): + """ + Check if the current node can participate in the sync process. + A node can participate if it is a supervisor and was full online in the previous epoch. + + Returns + ------- + bool : True if the node can participate in the sync process, False otherwise + """ + return self.__is_supervisor(self.cfg_dummy_sender) and self.__was_full_online(self.cfg_dummy_sender) + + # Utils + if True: + def _OracleSync01Plugin__is_supervisor(self, node: str): + """ + Check if the node is a supervisor. + + Parameters + ---------- + node : str + The node to check + + Returns + ------- + bool : True if the node is a supervisor, False otherwise + """ + return node in self.__get_oracle_list() + + def _OracleSync01Plugin__get_oracle_list(self): + """ + Get the list of oracles. + For now we consider that all supervisors are oracles. + + Returns + ------- + list : The list of oracles + """ + return ['sender0', 'sender1', 'sender2'] + + def get_received_messages_from_oracles(self): + """ + Get the messages received from the oracles. + This method returns a generator for memory efficiency. + + Returns + ------- + generator : The messages received from the oracles + """ + dct_messages = self.dataapi_struct_datas() + received_messages = [dct_messages[i] for i in range(len(dct_messages))] + + for message in received_messages: + message[self.ct.PAYLOAD_DATA.EE_SENDER] = message['DUMMY_SENDER'] + + # we use a DCT that already filters the messages from the oracles + received_messages_from_oracles = received_messages + return received_messages_from_oracles + + def add_payload_by_fields(self, **kwargs): + """ + Add the payload to the message by fields. + + Parameters + ---------- + **kwargs : dict + The fields to add to the payload + """ + super(OracleSyncTest01Plugin, self).add_payload_by_fields(dummy_sender=self.cfg_dummy_sender, **kwargs) + return diff --git a/ver.py b/ver.py index 59373067..bbea61f0 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.58' +__VER__ = '2.0.59' From 9dece62eda38ba9dd5be7e22dde9582a935e1fb0 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Fri, 15 Nov 2024 12:02:49 +0200 Subject: [PATCH 40/61] fix: reduce log footprint for release mgr --- .../fastapi/launcher_download/naeural_release_app.py | 6 +++++- ver.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/extensions/business/fastapi/launcher_download/naeural_release_app.py b/extensions/business/fastapi/launcher_download/naeural_release_app.py index 824d9838..69433101 100644 --- a/extensions/business/fastapi/launcher_download/naeural_release_app.py +++ b/extensions/business/fastapi/launcher_download/naeural_release_app.py @@ -154,7 +154,11 @@ def _regenerate_index_html(self): # Add the latest release section latest_release = releases[0] - self.P("latest_release:\n{} ".format(self.json_dumps(latest_release, indent=2))) + dct_info = { + k : v for k, v in latest_release.items() + if k in ['tag_name', 'published_at', 'tarball_url', 'zipball_url', 'created_at'] + } + self.P("latest_release:\n{} ".format(self.json_dumps(dct_info, indent=2))) latest_release_section = f"""

Latest Release: {latest_release['tag_name'].replace("'","")}

diff --git a/ver.py b/ver.py index bbea61f0..3bdb952a 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.59' +__VER__ = '2.0.60' From 1075d5f41469148411564b1d9f275d5495b46aba Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Fri, 15 Nov 2024 15:15:24 +0200 Subject: [PATCH 41/61] doc: net config --- plugins/business/tutorials/net_config_monitor.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugins/business/tutorials/net_config_monitor.py b/plugins/business/tutorials/net_config_monitor.py index 341abde5..847b19d1 100644 --- a/plugins/business/tutorials/net_config_monitor.py +++ b/plugins/business/tutorials/net_config_monitor.py @@ -22,6 +22,13 @@ ] } +The full algoritm of this plugin is as follows: +1. At each iteration we check if data is avail from NET_MON_01. +2. If data is avail, we determine which nodes are allowed by which nodes from the list of active nodes. +3. For current node now I have the list of all nodes that allow me to connect to them. +4. At next iteration I will send COMMAND to UPDATE_MONITOR_01 for any required and allowed nodes. +5. If I receive data from UPDATE_MONITOR_01, I will *decrypt* it and update the list of pipelines for the sender node. + """ from naeural_core.business.base import BasePluginExecutor as BasePlugin From 11c8eba504407370f417960632f8a59a82003f50 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Fri, 15 Nov 2024 15:20:38 +0200 Subject: [PATCH 42/61] chore: inc ver --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index 3bdb952a..84225f55 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.60' +__VER__ = '2.0.61' From 2cad59c4e7e2321d5ac089d65bf1725e4939838b Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Fri, 15 Nov 2024 16:34:01 +0200 Subject: [PATCH 43/61] chore: cleaned minio default in admin --- .config_startup.json | 7 ------- ver.py | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/.config_startup.json b/.config_startup.json index 187df20d..60b99b0d 100644 --- a/.config_startup.json +++ b/.config_startup.json @@ -86,13 +86,6 @@ }, "ADMIN_PIPELINE" : { - "MINIO_MONIT_01": { - "MINIO_HOST" : null, - "MINIO_ACCESS_KEY" : null, - "MINIO_SECRET_KEY" : null, - "MINIO_SECURE" : null - }, - "NET_MON_01" : { "PROCESS_DELAY" : 10, diff --git a/ver.py b/ver.py index 84225f55..f7868765 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.61' +__VER__ = '2.0.62' From 61fea4cf5c219d35a5af4fbbc3fbf22340f1885b Mon Sep 17 00:00:00 2001 From: Stefan Saraev Date: Fri, 15 Nov 2024 18:34:10 +0200 Subject: [PATCH 44/61] feat(oracle_sync): finished remaining TODOs -> Please update naeural_core for this plugin to work correctly -> add instructions to deploy the plugin for the first time -> fixed a bug when the oracle receives epoch tables requested by another oracle (extract only the relevant interval from the response) -> go through the epochs in ascending order when syncing older epochs --- .../business/oracle_sync/oracle_sync_01.py | 43 ++++++++++++++++--- ver.py | 2 +- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/extensions/business/oracle_sync/oracle_sync_01.py b/extensions/business/oracle_sync/oracle_sync_01.py index 9d6fef57..75fcafce 100644 --- a/extensions/business/oracle_sync/oracle_sync_01.py +++ b/extensions/business/oracle_sync/oracle_sync_01.py @@ -39,6 +39,16 @@ "PATH_FILTER" : [None, None, "ORACLE_SYNC_01", None], "MESSAGE_FILTER" : {}, } + + +To deploy for the first time: +1. Set `last_epoch_synced = X-1` in epoch manager +2. Start boxes in epoch X-1, let them run through epoch X-1, and let them enter in epoch X +3. During epoch X, deploy the plugin on all oracles +4. The plugins will skip the first sync process, because current epoch (X) + is the same as the last epoch synced (X-1) + 1 +4. Let all oracles run through epoch X, until they enter epoch X+1 +5. When they enter epoch X+1, the plugin will start the sync process """ from naeural_core.business.base import BasePluginExecutor as BaseClass @@ -256,7 +266,7 @@ def __reset_to_initial_state(self): self.first_time_agreed_median_table_sent = None self.last_time_agreed_median_table_sent = None - self.__last_epoch_synced = 0 # TODO: change the initial value + self.__last_epoch_synced = self.netmon.epoch_manager.get_last_sync_epoch() self.first_time_request_agreed_median_table_sent = None self.last_time_request_agreed_median_table_sent = None return @@ -285,6 +295,10 @@ def __receive_requests_from_oracles_and_send_responses(self): start_epoch = oracle_data.get('START_EPOCH') end_epoch = oracle_data.get('END_EPOCH') + if stage != self.STATES.S8_SEND_REQUEST_AGREED_MEDIAN_TABLE: + # received a message from a different stage + continue + if request_agreed_median_table: self.P(f"Received request from oracle {sender}: {stage = }, {start_epoch = }, {end_epoch = }") self.__send_epoch__agreed_median_table(start_epoch, end_epoch) @@ -655,8 +669,12 @@ def __receive_agreed_median_table_and_maybe_request_agreed_median_table(self): sender = dct_message.get(self.ct.PAYLOAD_DATA.EE_SENDER) oracle_data = dct_message.get('ORACLE_DATA') dct_epoch_agreed_median_table = oracle_data.get('EPOCH__AGREED_MEDIAN_TABLE') + stage = oracle_data.get('STAGE') + + if stage != self.STATES.S0_WAIT_FOR_EPOCH_CHANGE: + # received a message from a different stage + continue - # TODO: check stage ok (from S0) if not self.__check_received_epoch__agreed_median_table_ok(sender, oracle_data): continue @@ -673,9 +691,16 @@ def __receive_agreed_median_table_and_maybe_request_agreed_median_table(self): # sort dct_epoch_agreed_median_table by epoch in ascending order received_epochs = sorted(dct_epoch_agreed_median_table.keys()) - self.P(f"Received availability table for epochs {received_epochs} from {sender = }") - self.dct_median_tables[sender] = dct_epoch_agreed_median_table + if self.__last_epoch_synced + 1 not in received_epochs or self.__current_epoch - 1 not in received_epochs: + # Expected epochs in range [last_epoch_synced + 1, current_epoch - 1] + # received epochs don t contain the full range + continue + + self.P(f"Received availability table for epochs {received_epochs} from {sender = }. Keeping only the " + f"tables for epochs in range [{self.__last_epoch_synced + 1}, {self.__current_epoch - 1}]") + self.dct_median_tables[sender] = {i: dct_epoch_agreed_median_table[i] + for i in range(self.__last_epoch_synced + 1, self.__current_epoch)} # end for received messages # Send request to get agreed value from oracles @@ -767,7 +792,15 @@ def __compute_requested_agreed_median_table(self): # end for epoch agreed table # end for received messages - for epoch, lst_agreed_median_table in dct_epoch_lst_agreed_median_table.items(): + # we make sure the epochs are in ascending order + epochs_in_order = sorted(dct_epoch_lst_agreed_median_table.keys()) + for epoch in epochs_in_order: + if epoch <= self.__last_epoch_synced: + self.P(f"Epoch {epoch} already synced " + f"(last_epoch_synced= {self.__last_epoch_synced}). Skipping", color='r') + # we already have the agreed median table for this epoch + continue + lst_agreed_median_table = dct_epoch_lst_agreed_median_table[epoch] # im expecting all median tables to contain all nodes # but some errors can occur, so this does no harm all_nodes = set().union(*(set(value_table.keys()) for value_table in lst_agreed_median_table)) diff --git a/ver.py b/ver.py index f7868765..02c923c9 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.62' +__VER__ = '2.0.63' From 3b2ab8ef4c387f489f537d368ecd3da4efa099c8 Mon Sep 17 00:00:00 2001 From: Stefan Saraev Date: Fri, 15 Nov 2024 18:43:18 +0200 Subject: [PATCH 45/61] fix(oracle_sync): update last epoch synced after updating availability table -> we do this because the update_epoch_availability method can raise an exception --- extensions/business/oracle_sync/oracle_sync_01.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/business/oracle_sync/oracle_sync_01.py b/extensions/business/oracle_sync/oracle_sync_01.py index 75fcafce..ca29002a 100644 --- a/extensions/business/oracle_sync/oracle_sync_01.py +++ b/extensions/business/oracle_sync/oracle_sync_01.py @@ -648,9 +648,9 @@ def __update_epoch_manager_with_agreed_median_table(self, epoch=None, agreed_med f"Current epoch {epoch}", color='r') return - self.__last_epoch_synced = epoch - self.netmon.epoch_manager.update_epoch_availability(epoch, agreed_median_table) + + self.__last_epoch_synced = epoch return # S8_SEND_REQUEST_AGREED_MEDIAN_TABLE From 9076e098829dd2fb440b256478f0205022cd7068 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Fri, 15 Nov 2024 20:45:15 +0200 Subject: [PATCH 46/61] chore: inc ver --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index 02c923c9..14093252 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.63' +__VER__ = '2.0.64' From 3ab598dfc9c514a4cc92c2eff6731cae24424af3 Mon Sep 17 00:00:00 2001 From: Cristi Bleotiu <164478159+cristibleotiu@users.noreply.github.com> Date: Fri, 15 Nov 2024 21:17:48 +0200 Subject: [PATCH 47/61] feat: added reset option for telegram_conversational_bot (#1) --- .../business/telegram/telegram_conversational_bot_01.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/extensions/business/telegram/telegram_conversational_bot_01.py b/extensions/business/telegram/telegram_conversational_bot_01.py index 1b392ae8..3695e314 100644 --- a/extensions/business/telegram/telegram_conversational_bot_01.py +++ b/extensions/business/telegram/telegram_conversational_bot_01.py @@ -187,6 +187,13 @@ def maybe_init_user_data(self, user): return def bot_msg_handler(self, message, user, **kwargs): + if message == '\\reset': + self.__user_data[user] = { + 'system_prompt': self.cfg_system_prompt or '', + 'messages': [] + } + return "Conversation history reset." + # endif conversation is reset self.maybe_init_user_data(user) self.P(f"Received message from {user}: {message}") response = self.get_response(user, message) From 8e918e9ee9f544d6317bec7a2d2e6bbf6870863b Mon Sep 17 00:00:00 2001 From: Andrei Ionut DAMIAN Date: Fri, 15 Nov 2024 21:18:27 +0200 Subject: [PATCH 48/61] fix: net config tuning (#2) - bigger buffer si but more important higher resolution (50Hz) - changed default edge node res to 10 Hz --- .config_startup.json | 2 +- .../business/tutorials/net_config_monitor.py | 141 ++++++++++-------- ver.py | 2 +- 3 files changed, 79 insertions(+), 66 deletions(-) diff --git a/.config_startup.json b/.config_startup.json index 60b99b0d..e38751de 100644 --- a/.config_startup.json +++ b/.config_startup.json @@ -2,7 +2,7 @@ "EE_ID": "XXXXXXXXXX", "SECURED" : true, "IO_FORMATTER" : "", - "MAIN_LOOP_RESOLUTION" : 5, + "MAIN_LOOP_RESOLUTION" : 10, "SYSTEM_TEMPERATURE_CHECK" : false, diff --git a/plugins/business/tutorials/net_config_monitor.py b/plugins/business/tutorials/net_config_monitor.py index 847b19d1..fb17fc58 100644 --- a/plugins/business/tutorials/net_config_monitor.py +++ b/plugins/business/tutorials/net_config_monitor.py @@ -39,8 +39,8 @@ **BasePlugin.CONFIG, 'ALLOW_EMPTY_INPUTS' : True, - - 'MAX_INPUTS_QUEUE_SIZE' : 16, + 'PLUGIN_LOOP_RESOLUTION' : 50, # we force this to be 50 Hz from the standard 20 Hz + 'MAX_INPUTS_QUEUE_SIZE' : 128, # increase the queue size to 128 from std 1 'PROCESS_DELAY' : 0, @@ -80,7 +80,8 @@ def __get_active_nodes(self, netmon_current_network : dict) -> dict: if v.get("working", False) == self.const.DEVICE_STATUS_ONLINE } return active_network - + + def __get_active_nodes_summary_with_peers(self, netmon_current_network: dict): """ Looks in all whitelists and finds the nodes that is allowed by most other nodes. @@ -172,8 +173,77 @@ def __maybe_send(self): #endif have allowed nodes #endif time to send return - - + + + def __maybe_process_netmon(self, current_network : dict): + if len(current_network) == 0: + self.P("Received NET_MON_01 data without CURRENT_NETWORK data.", color='r ') + else: + self.__new_nodes_this_iter = 0 + peers_status = self.__get_active_nodes_summary_with_peers(current_network) + if self.__debug_netmon_count > 0: + # self.P(f"NetMon debug:\n{self.json_dumps(self.__get_active_nodes(current_network), indent=2)}") + self.P(f"Peers status:\n{self.json_dumps(peers_status, indent=2)}") + self.__debug_netmon_count -= 1 + for addr in peers_status: + if addr == self.ee_addr: + # its us, no need to check whitelist + continue + if peers_status[addr]["allows_me"]: + # we have found a whitelist that contains our address + if addr not in self.__allowed_nodes: + self.__allowed_nodes[addr] = { + "whitelist" : peers_status[addr]["whitelist"], + "last_config_get" : 0 + } + self.__new_nodes_this_iter += 1 + #endif addr not in __allowed_nodes + #endif addr allows me + #endfor each addr in peers_status + if self.__new_nodes_this_iter > 0: + self.P(f"Found {self.__new_nodes_this_iter} new peered nodes.") + #endif len(current_network) == 0 + return + + + def __maybe_process_update_monitor_data(self, data: dict): + sender = data.get(self.const.PAYLOAD_DATA.EE_SENDER, None) + is_encrypted = data.get(self.const.PAYLOAD_DATA.EE_IS_ENCRYPTED, False) + encrypted_data = data.get(self.const.PAYLOAD_DATA.EE_ENCRYPTED_DATA, None) + if is_encrypted and encrypted_data is not None: + self.P("Received UPDATE_MONITOR_01 encrypted data. Decrypting...") + str_decrypted_data = self.bc.decrypt_str( + str_b64data=encrypted_data, + str_sender=sender, + ) + decrypted_data = self.json_loads(str_decrypted_data) + if decrypted_data is not None: + received_pipelines = decrypted_data.get("EE_PIPELINES", []) + self.P("Decrypted data size {} with {} pipelines (speed: {:.1f} Hz):\n{}".format( + len(str_decrypted_data), len(received_pipelines), + self.actual_plugin_resolution, + self.json_dumps([ + { + k:v for k,v in x.items() + if k in ["NAME", "TYPE", "MODIFIED_BY_ADDR", "LAST_UPDATE_TIME"] + } + for x in received_pipelines], + indent=2), + )) + sender_no_prefix = self.bc.maybe_remove_prefix(sender) + self.__allowed_nodes[sender_no_prefix]["pipelines"] = received_pipelines + # now we can add the pipelines to the netmon cache + else: + self.P("Failed to decrypt data.", color='r') + #endif decrypted_data is not None + else: + self.P("Received unencrypted data.") + if sender in self.__allowed_nodes: + # + self.P(f"Updated last_config_get for node '{sender}'") + return + + def __maybe_process_received(self): data = self.dataapi_struct_data() if data is not None: @@ -190,68 +260,11 @@ def __maybe_process_received(self): return if signature == "NET_MON_01": current_network = data.get("CURRENT_NETWORK", {}) - if len(current_network) == 0: - self.P("Received NET_MON_01 data without CURRENT_NETWORK data.", color='r ') - else: - self.__new_nodes_this_iter = 0 - peers_status = self.__get_active_nodes_summary_with_peers(current_network) - if self.__debug_netmon_count > 0: - # self.P(f"NetMon debug:\n{self.json_dumps(self.__get_active_nodes(current_network), indent=2)}") - self.P(f"Peers status:\n{self.json_dumps(peers_status, indent=2)}") - self.__debug_netmon_count -= 1 - for addr in peers_status: - if addr == self.ee_addr: - # its us, no need to check whitelist - continue - if peers_status[addr]["allows_me"]: - # we have found a whitelist that contains our address - if addr not in self.__allowed_nodes: - self.__allowed_nodes[addr] = { - "whitelist" : peers_status[addr]["whitelist"], - "last_config_get" : 0 - } - self.__new_nodes_this_iter += 1 - #endif addr not in __allowed_nodes - #endif addr allows me - #endfor each addr in peers_status - if self.__new_nodes_this_iter > 0: - self.P(f"Found {self.__new_nodes_this_iter} new peered nodes.") - #endif nodes_data is not None + self.__maybe_process_netmon(current_network) #endif signature == "NET_MON_01" elif signature == "UPDATE_MONITOR_01": - is_encrypted = data.get(self.const.PAYLOAD_DATA.EE_IS_ENCRYPTED, False) - encrypted_data = data.get(self.const.PAYLOAD_DATA.EE_ENCRYPTED_DATA, None) - if is_encrypted and encrypted_data is not None: - self.P("Received UPDATE_MONITOR_01 encrypted data. Decrypting...") - str_decrypted_data = self.bc.decrypt_str( - str_b64data=encrypted_data, - str_sender=sender, - ) - decrypted_data = self.json_loads(str_decrypted_data) - if decrypted_data is not None: - received_pipelines = decrypted_data.get("EE_PIPELINES", []) - self.P("Decrypted data size {} with {} pipelines:\n{}".format( - len(str_decrypted_data), len(received_pipelines), - self.json_dumps([ - { - k:v for k,v in x.items() - if k in ["NAME", "TYPE", "MODIFIED_BY_ADDR", "LAST_UPDATE_TIME"] - } - for x in received_pipelines], - indent=2), - )) - sender_no_prefix = self.bc.maybe_remove_prefix(sender) - self.__allowed_nodes[sender_no_prefix]["pipelines"] = received_pipelines - # now we can add the pipelines to the netmon cache - else: - self.P("Failed to decrypt data.", color='r') - #endif decrypted_data is not None - else: - self.P("Received unencrypted data.") - if sender in self.__allowed_nodes: - # - self.P(f"Updated last_config_get for node '{sender}'") + self.__maybe_process_update_monitor_data(data) #endif signature == "UPDATE_MONITOR_01" return diff --git a/ver.py b/ver.py index 14093252..0d1f0f65 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.64' +__VER__ = '2.0.65' From 29ac78d4fac23d11bdf9c7c93b05908ae72aff46 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Sat, 16 Nov 2024 17:40:15 +0200 Subject: [PATCH 49/61] chore: update actions --- .github/workflows/build_develop.yml | 12 ++++++++++++ ver.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build_develop.yml b/.github/workflows/build_develop.yml index aaa8d397..394468ef 100644 --- a/.github/workflows/build_develop.yml +++ b/.github/workflows/build_develop.yml @@ -23,6 +23,18 @@ jobs: git config --local user.email "action@github.com" git config --local user.name "GitHub Action" + - name: Retrieve version + id: retrieve_version + run: | + echo "VERSION=$(cat ver.py | grep -o "'.*'")" >> $GITHUB_ENV + + - name: Debug version + run: | + VERSION=${VERSION//\'/} + echo "Develop version to build: '$VERSION'" + env: + VERSION: ${{ env.VERSION }} + - name: Log in to Docker Hub uses: docker/login-action@v3 diff --git a/ver.py b/ver.py index 0d1f0f65..7f1e3428 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.65' +__VER__ = '2.0.66' From 08bd25587ae7febb299fc231a6b60ad4e0cf2220 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Sat, 16 Nov 2024 18:22:36 +0200 Subject: [PATCH 50/61] chore: prep simple local cluster --- .env.template | 6 ++++++ docker-compose.yaml | 37 +++++++++++++++++++++++++++++++++++++ start_cluster.bat | 8 ++++++++ start_cluster.sh | 9 +++++++++ 4 files changed, 60 insertions(+) create mode 100644 docker-compose.yaml create mode 100644 start_cluster.bat create mode 100644 start_cluster.sh diff --git a/.env.template b/.env.template index 1f32856f..f8c2e7c0 100644 --- a/.env.template +++ b/.env.template @@ -1,5 +1,6 @@ # LOCAL FILE TEMPLATE + EE_ID=your-node-name EE_SUPERVISOR=true/false # GPU setup: cpu or cuda @@ -33,3 +34,8 @@ EE_NGROK_EDGE_LABEL=ngrok-edge-label EE_GITVER=token_for_accessing_private_repositories EE_OPENAI=token_for_accessing_openai_api EE_HF_TOKEN=token_for_accessing_huggingface_api + + +# EE_ID_01=your-local-cluster-node-01-name +# EE_ID_02=your-local-cluster-node-02-name +# EE_ID_03=your-local-cluster-node-03-name diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000..7b0fff03 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,37 @@ +version: '3.8' + +services: + naeural_01: + image: naeural/edge_node:develop + container_name: naeural_01 + restart: always + environment: + EE_ID: ${EE_ID_01?You must set the EE_ID_01 environment variable} + env_file: .env + volumes: + - naeural_01:/edge_node/_local_cache + + naeural_02: + image: naeural/edge_node:develop + container_name: naeural_02 + restart: always + environment: + EE_ID: ${EE_ID_02?You must set the EE_ID_02 environment variable} + env_file: .env + volumes: + - naeural_02:/edge_node/_local_cache + + naeural_03: + image: naeural/edge_node:develop + container_name: naeural_03 + restart: always + environment: + EE_ID: ${EE_ID_03?You must set the EE_ID_03 environment variable3} + env_file: .env + volumes: + - naeural_03:/edge_node/_local_cache + +volumes: + naeural_01: + naeural_02: + naeural_03: diff --git a/start_cluster.bat b/start_cluster.bat new file mode 100644 index 00000000..187d9236 --- /dev/null +++ b/start_cluster.bat @@ -0,0 +1,8 @@ +@echo off +REM Pull the latest images +docker-compose pull + +REM Start the containers +docker-compose up -d + +echo Containers are starting... diff --git a/start_cluster.sh b/start_cluster.sh new file mode 100644 index 00000000..927d7eef --- /dev/null +++ b/start_cluster.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +# Pull the latest images +docker-compose pull + +# Start the containers in detached mode +docker-compose up -d + +echo "Containers are starting..." From b8f5e4a4a60c7e5e57403dab52d8ced13ae04a41 Mon Sep 17 00:00:00 2001 From: Andrei Ionut DAMIAN Date: Sat, 16 Nov 2024 19:13:31 +0200 Subject: [PATCH 51/61] fix: remove net config and prep for new admin pipelines (#3) --- .config_startup.json | 7 +- .../business/tutorials/net_config_monitor.py | 279 ------------------ ver.py | 2 +- 3 files changed, 6 insertions(+), 282 deletions(-) delete mode 100644 plugins/business/tutorials/net_config_monitor.py diff --git a/.config_startup.json b/.config_startup.json index e38751de..ebf49dc8 100644 --- a/.config_startup.json +++ b/.config_startup.json @@ -86,10 +86,13 @@ }, "ADMIN_PIPELINE" : { + + "NET_CONFIG_MONITOR" : { + "PROCESS_DELAY" : 0 + }, "NET_MON_01" : { - "PROCESS_DELAY" : 10, - "SUPERVISOR" : "$EE_SUPERVISOR" + "PROCESS_DELAY" : 10 }, "UPDATE_MONITOR_01" : { diff --git a/plugins/business/tutorials/net_config_monitor.py b/plugins/business/tutorials/net_config_monitor.py deleted file mode 100644 index fb17fc58..00000000 --- a/plugins/business/tutorials/net_config_monitor.py +++ /dev/null @@ -1,279 +0,0 @@ -""" -{ - "NAME" : "peer_config_pipeline", - "TYPE" : "NetworkListener", - - "PATH_FILTER" : [ - null, null, - ["UPDATE_MONITOR_01", "NET_MON_01"], - null - ], - "MESSAGE_FILTER" : {}, - - "PLUGINS" : [ - { - "SIGNATURE" : "NET_CONFIG_MONITOR", - "INSTANCES" : [ - { - "INSTANCE_ID" : "DEFAULT" - } - ] - } - ] -} - -The full algoritm of this plugin is as follows: -1. At each iteration we check if data is avail from NET_MON_01. -2. If data is avail, we determine which nodes are allowed by which nodes from the list of active nodes. -3. For current node now I have the list of all nodes that allow me to connect to them. -4. At next iteration I will send COMMAND to UPDATE_MONITOR_01 for any required and allowed nodes. -5. If I receive data from UPDATE_MONITOR_01, I will *decrypt* it and update the list of pipelines for the sender node. - -""" -from naeural_core.business.base import BasePluginExecutor as BasePlugin - - -__VER__ = '0.1.0' - -_CONFIG = { - - **BasePlugin.CONFIG, - 'ALLOW_EMPTY_INPUTS' : True, - 'PLUGIN_LOOP_RESOLUTION' : 50, # we force this to be 50 Hz from the standard 20 Hz - 'MAX_INPUTS_QUEUE_SIZE' : 128, # increase the queue size to 128 from std 1 - - 'PROCESS_DELAY' : 0, - - 'SEND_EACH' : 10, - - 'REQUEST_CONFIGS_EACH' : 30, - - 'SHOW_EACH' : 60, - - 'DEBUG_NETMON_COUNT' : 2, - - 'VALIDATION_RULES' : { - **BasePlugin.CONFIG['VALIDATION_RULES'], - }, -} - -class NetConfigMonitorPlugin(BasePlugin): - - - def on_init(self): - self.P("Network peer config watch demo initializing...") - self.__last_data_time = 0 - self.__new_nodes_this_iter = 0 - self.__last_shown = 0 - self.__allowed_nodes = {} # contains addresses with no prefixes - self.__debug_netmon_count = self.cfg_debug_netmon_count - return - - - def __get_active_nodes(self, netmon_current_network : dict) -> dict: - """ - Returns a dictionary with the active nodes in the network. - """ - active_network = { - v['address']: v - for k, v in netmon_current_network.items() - if v.get("working", False) == self.const.DEVICE_STATUS_ONLINE - } - return active_network - - - def __get_active_nodes_summary_with_peers(self, netmon_current_network: dict): - """ - Looks in all whitelists and finds the nodes that is allowed by most other nodes. - - """ - node_coverage = {} - - active_network = self.__get_active_nodes(netmon_current_network) - - for addr in active_network: - node_coverage[addr] = 0 - #endfor initialize node_coverage - - whitelists = [x.get("whitelist", []) for x in active_network.values()] - for whitelist in whitelists: - for ee_addr in whitelist: - if ee_addr not in active_network: - continue # this address is not active in the network so we skip it - if ee_addr not in node_coverage: - node_coverage[ee_addr] = 0 - node_coverage[ee_addr] += 1 - coverage_list = [(k, v) for k, v in node_coverage.items()] - coverage_list = sorted(coverage_list, key=lambda x: x[1], reverse=True) - - result = self.OrderedDict() - my_addr = self.bc.maybe_remove_prefix(self.ee_addr) - - for i, (ee_addr, coverage) in enumerate(coverage_list): - is_online = active_network.get(ee_addr, {}).get("working", False) == self.const.DEVICE_STATUS_ONLINE - result[ee_addr] = { - "peers" : coverage, - "eeid" : active_network.get(ee_addr, {}).get("eeid", "UNKNOWN"), - 'ver' : active_network.get(ee_addr, {}).get("version", "UNKNOWN"), - 'is_supervisor' : active_network.get(ee_addr, {}).get("is_supervisor", False), - 'allows_me' : my_addr in active_network.get(ee_addr, {}).get("whitelist", []), - 'online' : is_online, - 'whitelist' : active_network.get(ee_addr, {}).get("whitelist", []), - } - return result - - - def __maybe_review_known(self): - if ((self.time() - self.__last_shown) < self.cfg_show_each) or (len(self.__allowed_nodes) == 0): - return - self.__last_shown = self.time() - msg = "Known nodes: " - for addr in self.__allowed_nodes: - eeid = self.netmon.network_node_eeid(addr) - pipelines = self.__allowed_nodes[addr].get("pipelines", []) - names = [p.get("NAME", "NONAME") for p in pipelines] - msg += f"\n - '{eeid}' <{addr}> has {len(pipelines)} pipelines: {names}" - #endfor __allowed_nodes - self.P(msg) - return - - - def __maybe_send(self): - if self.time() - self.__last_data_time > self.cfg_send_each: - self.__last_data_time = self.time() - if len(self.__allowed_nodes) == 0: - self.P("No allowed nodes to send requests to. Waiting for network data...") - else: - self.P("Initiating pipeline requests to allowed nodes...") - to_send = [] - for node_addr in self.__allowed_nodes: - last_request = self.__allowed_nodes[node_addr].get("last_config_get", 0) - if (self.time() - last_request) > self.cfg_request_configs_each: - to_send.append(node_addr) - #endif enough time since last request of this node - #endfor __allowed_nodes - if len(to_send) == 0: - self.P("No nodes need update.") - else: - self.P(f"Local {len(self.local_pipelines)} pipelines. Sending requests to {len(to_send)} nodes...") - # now send some requests - for node_addr in to_send: - node_ee_id = self.netmon.network_node_eeid(node_addr) - self.P(f"Sending GET_PIPELINES to '{node_ee_id}' <{node_addr}>...") - self.cmdapi_send_instance_command( - pipeline="admin_pipeline", - signature="UPDATE_MONITOR_01", - instance_id="UPDATE_MONITOR_01_INST", - instance_command={ "COMMAND": "GET_PIPELINES" }, - node_address=node_addr, - ) - self.__allowed_nodes[node_addr]["last_config_get"] = self.time() - #endfor to_send - #endif len(to_send) == 0 - #endif have allowed nodes - #endif time to send - return - - - def __maybe_process_netmon(self, current_network : dict): - if len(current_network) == 0: - self.P("Received NET_MON_01 data without CURRENT_NETWORK data.", color='r ') - else: - self.__new_nodes_this_iter = 0 - peers_status = self.__get_active_nodes_summary_with_peers(current_network) - if self.__debug_netmon_count > 0: - # self.P(f"NetMon debug:\n{self.json_dumps(self.__get_active_nodes(current_network), indent=2)}") - self.P(f"Peers status:\n{self.json_dumps(peers_status, indent=2)}") - self.__debug_netmon_count -= 1 - for addr in peers_status: - if addr == self.ee_addr: - # its us, no need to check whitelist - continue - if peers_status[addr]["allows_me"]: - # we have found a whitelist that contains our address - if addr not in self.__allowed_nodes: - self.__allowed_nodes[addr] = { - "whitelist" : peers_status[addr]["whitelist"], - "last_config_get" : 0 - } - self.__new_nodes_this_iter += 1 - #endif addr not in __allowed_nodes - #endif addr allows me - #endfor each addr in peers_status - if self.__new_nodes_this_iter > 0: - self.P(f"Found {self.__new_nodes_this_iter} new peered nodes.") - #endif len(current_network) == 0 - return - - - def __maybe_process_update_monitor_data(self, data: dict): - sender = data.get(self.const.PAYLOAD_DATA.EE_SENDER, None) - is_encrypted = data.get(self.const.PAYLOAD_DATA.EE_IS_ENCRYPTED, False) - encrypted_data = data.get(self.const.PAYLOAD_DATA.EE_ENCRYPTED_DATA, None) - if is_encrypted and encrypted_data is not None: - self.P("Received UPDATE_MONITOR_01 encrypted data. Decrypting...") - str_decrypted_data = self.bc.decrypt_str( - str_b64data=encrypted_data, - str_sender=sender, - ) - decrypted_data = self.json_loads(str_decrypted_data) - if decrypted_data is not None: - received_pipelines = decrypted_data.get("EE_PIPELINES", []) - self.P("Decrypted data size {} with {} pipelines (speed: {:.1f} Hz):\n{}".format( - len(str_decrypted_data), len(received_pipelines), - self.actual_plugin_resolution, - self.json_dumps([ - { - k:v for k,v in x.items() - if k in ["NAME", "TYPE", "MODIFIED_BY_ADDR", "LAST_UPDATE_TIME"] - } - for x in received_pipelines], - indent=2), - )) - sender_no_prefix = self.bc.maybe_remove_prefix(sender) - self.__allowed_nodes[sender_no_prefix]["pipelines"] = received_pipelines - # now we can add the pipelines to the netmon cache - else: - self.P("Failed to decrypt data.", color='r') - #endif decrypted_data is not None - else: - self.P("Received unencrypted data.") - if sender in self.__allowed_nodes: - # - self.P(f"Updated last_config_get for node '{sender}'") - return - - - def __maybe_process_received(self): - data = self.dataapi_struct_data() - if data is not None: - payload_path = data.get(self.const.PAYLOAD_DATA.EE_PAYLOAD_PATH, [None, None, None, None]) - eeid = payload_path[0] - signature = payload_path[2] - sender = data.get(self.const.PAYLOAD_DATA.EE_SENDER, None) - is_encrypted = data.get(self.const.PAYLOAD_DATA.EE_IS_ENCRYPTED, False) - self.P("Received {}'{}' data from {}".format( - "ENC " if is_encrypted else "", - signature, f"'{eeid}' <{sender}>" if sender != self.ee_addr else "SELF", - )) - if sender == self.ee_addr: - return - if signature == "NET_MON_01": - current_network = data.get("CURRENT_NETWORK", {}) - self.__maybe_process_netmon(current_network) - #endif signature == "NET_MON_01" - - elif signature == "UPDATE_MONITOR_01": - self.__maybe_process_update_monitor_data(data) - #endif signature == "UPDATE_MONITOR_01" - - return - - - def process(self): - payload = None - self.__maybe_send() - self.__maybe_process_received() - self.__maybe_review_known() - return payload - diff --git a/ver.py b/ver.py index 7f1e3428..fb6c9874 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.66' +__VER__ = '2.0.67' From f6ba71898bf5ac8f0f822f3cc2fa3da65a2cf9a4 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Sat, 16 Nov 2024 19:23:40 +0200 Subject: [PATCH 52/61] chore: inc ver for core update --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index fb6c9874..28fd6485 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.67' +__VER__ = '2.0.68' From f75bbe482aa5bb7aba8b9b9ac2e87f8168e4c3ac Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Sat, 16 Nov 2024 21:25:40 +0200 Subject: [PATCH 53/61] chore: bump for net config release --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index 28fd6485..713ae2fb 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.0.68' +__VER__ = '2.1.0' From f65ab56b35e6b22f76d01d8126e54c623c033763 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Sun, 17 Nov 2024 09:16:38 +0200 Subject: [PATCH 54/61] chore: inc ver --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index 713ae2fb..fafd68de 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.1.0' +__VER__ = '2.1.1' From 88c8dd996b45d7747a8ab78df0f32bcd8df17f80 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Sun, 17 Nov 2024 09:32:35 +0200 Subject: [PATCH 55/61] chore: action checking and debugging --- .github/workflows/build_develop.yml | 13 ++++++++++++- .github/workflows/build_main.yml | 17 +++++++++++++++-- ver.py | 2 +- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_develop.yml b/.github/workflows/build_develop.yml index 394468ef..6dac5a9c 100644 --- a/.github/workflows/build_develop.yml +++ b/.github/workflows/build_develop.yml @@ -23,18 +23,29 @@ jobs: git config --local user.email "action@github.com" git config --local user.name "GitHub Action" - - name: Retrieve version + ## version getting and debugging + + - name: Retrieve edge node version id: retrieve_version run: | echo "VERSION=$(cat ver.py | grep -o "'.*'")" >> $GITHUB_ENV + - name: Check latest naeural_core version + id: check_core_latest_version + run: | + LATEST_VERSION=$(curl -s https://pypi.org/pypi/naeural-core/json | jq -r '.info.version') + echo "LATEST_NAEURAL_CORE_VERSION=$LATEST_VERSION" >> $GITHUB_ENV + - name: Debug version run: | VERSION=${VERSION//\'/} echo "Develop version to build: '$VERSION'" + echo "Latest naeural_core version on PyPI: '$LATEST_NAEURAL_CORE_VERSION'" env: VERSION: ${{ env.VERSION }} + LATEST_NAEURAL_CORE_VERSION: ${{ env.LATEST_NAEURAL_CORE_VERSION }} + ## End of version getting and debugging - name: Log in to Docker Hub uses: docker/login-action@v3 diff --git a/.github/workflows/build_main.yml b/.github/workflows/build_main.yml index ee2c88b6..53e4186f 100644 --- a/.github/workflows/build_main.yml +++ b/.github/workflows/build_main.yml @@ -23,17 +23,30 @@ jobs: git config --local user.email "action@github.com" git config --local user.name "GitHub Action" - - name: Retrieve version + + ## version getting and debugging + + - name: Retrieve edge node version id: retrieve_version run: | echo "VERSION=$(cat ver.py | grep -o "'.*'")" >> $GITHUB_ENV + - name: Check latest naeural_core version + id: check_core_latest_version + run: | + LATEST_VERSION=$(curl -s https://pypi.org/pypi/naeural-core/json | jq -r '.info.version') + echo "LATEST_NAEURAL_CORE_VERSION=$LATEST_VERSION" >> $GITHUB_ENV + - name: Debug version run: | VERSION=${VERSION//\'/} - echo "Version to tag: '$VERSION'" + echo "Develop version to build: '$VERSION'" + echo "Latest naeural_core version on PyPI: '$LATEST_NAEURAL_CORE_VERSION'" env: VERSION: ${{ env.VERSION }} + LATEST_NAEURAL_CORE_VERSION: ${{ env.LATEST_NAEURAL_CORE_VERSION }} + + ## End of version getting and debugging - name: Create image tag id: create_image_tag diff --git a/ver.py b/ver.py index fafd68de..7685d44c 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.1.1' +__VER__ = '2.1.2' From 9b1556e6b39c6fe55e12561c35e67aeaaa3543f0 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Sun, 17 Nov 2024 10:07:16 +0200 Subject: [PATCH 56/61] chore: core sync; remove obsolete tag from compose yaml --- docker-compose.yaml | 2 -- ver.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 7b0fff03..1936d512 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: naeural_01: image: naeural/edge_node:develop diff --git a/ver.py b/ver.py index 7685d44c..2eb66f7a 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.1.2' +__VER__ = '2.1.3' From d1eddc982d974a2a7dcbf3e36fd4fe08131efd4b Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Sun, 17 Nov 2024 10:44:03 +0200 Subject: [PATCH 57/61] chore: inc ver for sync --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index 2eb66f7a..be9efbc9 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.1.3' +__VER__ = '2.1.4' From 24b98b7e0946d56317e4c70574b5578b2f1587cf Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Sun, 17 Nov 2024 21:27:38 +0200 Subject: [PATCH 58/61] chore: sync core --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index be9efbc9..80bb0e04 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.1.4' +__VER__ = '2.1.5' From d92172642d06c951561791ede3c78a429a9e89a4 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Sun, 17 Nov 2024 21:39:36 +0200 Subject: [PATCH 59/61] chore: sync sdk --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index 80bb0e04..a4bd9991 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.1.5' +__VER__ = '2.1.6' From 8e8c3a055307baa655e1547e12ce30736a0360d8 Mon Sep 17 00:00:00 2001 From: Andrei Ionut Damian Date: Mon, 18 Nov 2024 13:40:45 +0200 Subject: [PATCH 60/61] chore: sync libs --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index a4bd9991..97524ae3 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.1.6' +__VER__ = '2.1.7' From d0a9e2458b4c1cb0e4e4990accff48ed3d63012a Mon Sep 17 00:00:00 2001 From: Cristi Bleotiu <164478159+cristibleotiu@users.noreply.github.com> Date: Wed, 20 Nov 2024 18:11:08 +0200 Subject: [PATCH 61/61] chore: inc ver for merge (#4) --- ver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ver.py b/ver.py index 97524ae3..201198a0 100644 --- a/ver.py +++ b/ver.py @@ -1 +1 @@ -__VER__ = '2.1.7' +__VER__ = '2.1.8'