From dc86b3209e12f0a978976ad266386db3f1fa2637 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 5 Jan 2026 06:04:32 +0100 Subject: [PATCH 01/65] add(event): events to serialize and deserialize --- src/core/events.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/core/events.py b/src/core/events.py index e69de29..a8e6658 100644 --- a/src/core/events.py +++ b/src/core/events.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from typing import Dict, Sequence, List, Mapping, Any +import json + + +@dataclass +class ModuleEvent: + """ + Inter-Module communication event + Module subscribe to a topic and link a callback. + The payload must correspond to a mapping of params of the callback. + """ + + topic: str + payload: Mapping[str, Any] + + @classmethod + def from_dict(cls, raw: Dict): + return cls(topic=raw["topic"], payload=raw["payload"]) + + def serialize(self) -> Sequence: + return [self.topic.encode(), json.dumps(self.payload).encode()] + + @classmethod + def deserialize(cls, raw: List[bytes]): + topic, payload = raw + return cls(topic=topic.decode(), payload=json.loads(payload.decode())) From 44680ff47e341b1a24719fe68f445f5ad805983e Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 5 Jan 2026 06:18:22 +0100 Subject: [PATCH 02/65] evol(module): now use ModuleEvent --- src/core/module.py | 52 ++++++++++++++++++++-------------------------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/src/core/module.py b/src/core/module.py index 4be1246..6e0d29e 100644 --- a/src/core/module.py +++ b/src/core/module.py @@ -1,12 +1,12 @@ -import json import threading from abc import ABC, abstractmethod from multiprocessing.synchronize import Event -from typing import Callable, Dict, final +from typing import Callable, Dict, final, Any, Mapping import zmq from src.tools.logger import logging +from src.core.events import ModuleEvent class Module(ABC): @@ -51,19 +51,21 @@ def subscribe(self, topic: str, callback: Callable) -> None: @final def publish( - self, topic: str, msg: object, content_type: str = "str" - ) -> None: # TODO content type enum - if content_type == "json": - payload = json.dumps(msg).encode() - elif content_type == "bytes": - payload = msg - elif content_type == "str": - payload = msg.encode() - else: - raise ValueError(f"Unsupported content_type: {content_type}") - - self.pub_socket.send_multipart([topic.encode(), content_type.encode(), payload]) - self.logger.info(f"Publish: {topic} {content_type}") + self, + topic: str, + **kwargs: Mapping[str, Any], + ) -> None: + """ + Will publish a ModuleEvent to other modules. + + :param topic: the topic of the event + :type topic: str + :param kwargs: kwargs must be named as the receiving module's callbacks + :type kwargs: Mapping[str, Any] + """ + event = ModuleEvent(topic=topic, payload=kwargs) + self.logger.info(f"Publish: {topic} {kwargs.keys()}") + self.pub_socket.send_multipart(event.serialize()) @final def _start_polling(self) -> None: @@ -80,21 +82,11 @@ def _poll_loop(self) -> None: events = dict(poller.poll(100)) for _, sub in self.subs.items(): if sub in events: - topic, content_type, payload = sub.recv_multipart() - topic_str = topic.decode() - content_type_str = content_type.decode() - self.logger.info(f"Receive: {topic_str} {content_type_str}") - if content_type_str == "json": - kwargs = json.loads(payload.decode()) - self.callbacks[topic_str]( - **kwargs - ) # TODO better and cleaner way ? - elif content_type_str == "bytes": - data = payload - self.callbacks[topic_str](data) - elif content_type_str == "str": - data = payload.decode() - self.callbacks[topic_str](data) + data = sub.recv_multipart() + event = ModuleEvent.deserialize(data) + + self.logger.info(f"Receive: {event.topic} {event.payload.keys()}") + self.callbacks[event.topic](**event.payload) @final def start_module( From 4859fd03f67fa239384f720fc62ebecd1ea2bd41 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 5 Jan 2026 06:57:35 +0100 Subject: [PATCH 03/65] evol(modules): use of ModuleEvent --- src/core/agent.py | 2 +- src/core/zmq/event_proxy.py | 12 +++++++++--- src/modules/rag/mode_controller.py | 4 ++-- src/modules/rag/rag.py | 5 +++-- src/modules/speech_to_text/record_speech.py | 6 +++--- src/modules/speech_to_text/speech_to_text.py | 2 +- src/modules/textIO/input.py | 4 ++-- src/modules/textIO/output.py | 2 +- 8 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/core/agent.py b/src/core/agent.py index df2c182..cb7f5e7 100644 --- a/src/core/agent.py +++ b/src/core/agent.py @@ -274,4 +274,4 @@ def run(self) -> None: while True: data = input() - self.down_proxy.publish("std.in", data) + self.down_proxy.publish("std.in", data=data) diff --git a/src/core/zmq/event_proxy.py b/src/core/zmq/event_proxy.py index 9447de8..66d2b4c 100644 --- a/src/core/zmq/event_proxy.py +++ b/src/core/zmq/event_proxy.py @@ -1,9 +1,10 @@ from dataclasses import dataclass -from typing import Optional +from typing import Optional, Mapping, Any import zmq from src.tools.logger import logging, setup_logger +from src.core.events import ModuleEvent @dataclass @@ -53,6 +54,11 @@ def stop(self) -> None: self.xsub.close(linger=0) self.xpub.close(linger=0) - def publish(self, topic: str, msg: str) -> None: - self.xpub.send_multipart([topic.encode(), "str".encode(), msg.encode()]) + def publish( + self, + topic: str, + **kwargs: Mapping[str, Any], + ) -> None: + event = ModuleEvent(topic=topic, payload=kwargs) + self.xpub.send_multipart(event.serialize()) self.logger.info(f"Publish: {topic} str") diff --git a/src/modules/rag/mode_controller.py b/src/modules/rag/mode_controller.py index 9ed0606..e4b6c09 100644 --- a/src/modules/rag/mode_controller.py +++ b/src/modules/rag/mode_controller.py @@ -25,12 +25,12 @@ def processTextInput(self, text: str): elif "switch rag" in text.lower(): self.switchMode(Modes.RAG) elif "bye bye" in text.lower(): - self.publish("exit", "") # TODO handle (manager being a module) usefull ? + self.publish("exit") # TODO handle (manager being a module) usefull ? elif text.strip() == "": return else: topic = f"{str(self.mode.name).lower()}.in" - self.publish(topic, text) + self.publish(topic, text=text) def set_subscriptions(self): self.subscribe("text.in", self.processTextInput) diff --git a/src/modules/rag/rag.py b/src/modules/rag/rag.py index f2fc736..b083abd 100644 --- a/src/modules/rag/rag.py +++ b/src/modules/rag/rag.py @@ -62,7 +62,8 @@ def ragLoad(self, folderPath: str, fileType: str) -> None: self.documents += self.textSplitter.split_documents(fileLoader.load()) self.vectorstore.add_documents(self.documents) - def ragQuestion(self, question: str) -> None: + def ragQuestion(self, text: str) -> None: + question = text self.logger.debug("question:", question) history = "\n".join( [ @@ -79,7 +80,7 @@ def ragQuestion(self, question: str) -> None: self.conversation_log["conversation"].append( {"question": question.split(helpingContext)[1:], "answer": answer} ) - self.publish("llm.response", answer) + self.publish("llm.response", text=answer) def saveConversation(self, filename: str = "conversation_log.json"): with open(filename, "w") as f: diff --git a/src/modules/speech_to_text/record_speech.py b/src/modules/speech_to_text/record_speech.py index a361ac3..3d87edc 100644 --- a/src/modules/speech_to_text/record_speech.py +++ b/src/modules/speech_to_text/record_speech.py @@ -77,14 +77,14 @@ def record_audio(self, starting_chunk, stop_event: Event = None) -> None: if buffer == []: break speech = np.concatenate(buffer, axis=0) - self.publish("speech.in", speech.tobytes(), "bytes") + self.publish("speech.in", buffer=speech.tobytes()) break else: silence_start = None def set_subscriptions(self) -> None: - self.subscribe("speech.in.pause", self.pause()) - self.subscribe("speech.in.resume", self.pause(False)) + self.subscribe("speech.in.pause", lambda: self.pause()) + self.subscribe("speech.in.resume", lambda: self.pause(False)) def run_module(self, stop_event: Event = None) -> None: if not self.THRESHOLD: diff --git a/src/modules/speech_to_text/speech_to_text.py b/src/modules/speech_to_text/speech_to_text.py index a086a2c..6c087b0 100644 --- a/src/modules/speech_to_text/speech_to_text.py +++ b/src/modules/speech_to_text/speech_to_text.py @@ -44,7 +44,7 @@ def process_audio(self, buffer: bytes) -> None: if not result["text"] or result["text"] == "": return - self.publish("text.in", result["text"]) + self.publish("text.in", text=result["text"]) def set_subscriptions(self) -> None: self.subscribe("speech.in", self.process_audio) diff --git a/src/modules/textIO/input.py b/src/modules/textIO/input.py index 6440238..58ee567 100644 --- a/src/modules/textIO/input.py +++ b/src/modules/textIO/input.py @@ -4,13 +4,13 @@ class TextInput(Module): def set_subscriptions(self): self.subscribe("std.in", self.stdin_to_text) - self.subscribe("std.out", lambda _: print(">> ", end="", flush=True)) + self.subscribe("std.out", lambda: print(">> ", end="", flush=True)) def stdin_to_text(self, data): print(">> ", end="", flush=True) if data == "": return - self.publish("text.in", data) + self.publish("text.in", text=data) def run_module(self, stop_event=None): print(">> ", end="", flush=True) diff --git a/src/modules/textIO/output.py b/src/modules/textIO/output.py index c68ca76..c461e7a 100644 --- a/src/modules/textIO/output.py +++ b/src/modules/textIO/output.py @@ -7,4 +7,4 @@ def set_subscriptions(self) -> None: def print_response(self, text: str) -> None: print(f"\r<< {text}") - self.publish("std.out", "") + self.publish("std.out") From 4b90c4a89aa17b643a6e3265639ba1dcb3b317d6 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Wed, 14 Jan 2026 11:32:13 +0100 Subject: [PATCH 04/65] wip(command): add CommandEvent (is not received yet) --- src/core/agent.py | 31 +++++--- src/core/events.py | 39 +++++++++- src/core/module.py | 4 +- src/core/shell.py | 10 +-- src/core/zmq/control_channel.py | 121 +++++++++++++++++--------------- src/core/zmq/event_proxy.py | 8 +-- src/launch_agent.py | 2 +- 7 files changed, 135 insertions(+), 80 deletions(-) diff --git a/src/core/agent.py b/src/core/agent.py index cb7f5e7..32d5f51 100644 --- a/src/core/agent.py +++ b/src/core/agent.py @@ -5,11 +5,13 @@ from multiprocessing.synchronize import Event from typing import Any, Dict, Mapping +from src.core.events import Command, CommandEvent + from src.modules.factory import ModuleFactory from src.tools.logger import logging, setup_logger from .huri import HuriConfig -from .zmq.control_channel import Command, Dealer +from .zmq.control_channel import Dealer from .zmq.event_proxy import EventProxy from .zmq.log_channel import LogPusher @@ -117,14 +119,24 @@ def __init__(self, config: AgentConfig) -> None: f"Agent {self.dealer.identity}", log_queue=self.log_pusher.log_queue ) - def _command_handler(self, command: Command) -> bool: + def _command_handler(self, command: CommandEvent) -> bool: match command.cmd: - case "START": - return self.start_module(*command.args) - case "STOP": - return self.stop_module(*command.args) - case "STATUS": + case Command.START: + for name in self.modules: + self.start_module(name) + pass + case Command.STOP: + for name in list(self.processes.keys()): + self.stop_module(name) + case Command.START_MODULE: + return self.start_module(**command.payload) + case Command.STOP_MODULE: + return self.stop_module(**command.payload) + case Command.STATUS: return self.status() + case Command.EXIT: + self.exit() + pass case _: return False # todo log @@ -202,7 +214,7 @@ def stop_module(self, name) -> None: del self.stop_events[name] self.log_pusher.level_filter.del_level(name) - def stop_all(self) -> None: + def exit(self) -> None: for name in list(self.processes.keys()): self.stop_module(name) @@ -269,9 +281,6 @@ def run(self) -> None: self.logger.error(e) return - for name in self.modules: - self.start_module(name) - while True: data = input() self.down_proxy.publish("std.in", data=data) diff --git a/src/core/events.py b/src/core/events.py index a8e6658..38051b6 100644 --- a/src/core/events.py +++ b/src/core/events.py @@ -1,6 +1,7 @@ -from dataclasses import dataclass -from typing import Dict, Sequence, List, Mapping, Any import json +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, List, Mapping, Sequence @dataclass @@ -25,3 +26,37 @@ def serialize(self) -> Sequence: def deserialize(cls, raw: List[bytes]): topic, payload = raw return cls(topic=topic.decode(), payload=json.loads(payload.decode())) + + +class Command(Enum): + REGISTER = "REGISTER" + START = "START" + STOP = "STOP" + START_MODULE = "START_MODULE" + STOP_MODULE = "STOP_MODULE" + STATUS = "STATUS" + EXIT = "EXIT" + + +@dataclass +class CommandEvent: + cmd: Command + payload: Mapping[str, Any] + + @classmethod + def from_dict(cls, raw: Dict): + return cls(cmd=Command(raw["topic"]), payload=raw["payload"]) + + def serialize(self) -> Sequence: + print(self.cmd.value) + + return [ + self.cmd.value.encode(), + json.dumps(self.payload).encode(), + ] + + @classmethod + def deserialize(cls, raw: List[bytes]): + print(raw) + cmd, payload = raw + return cls(cmd=Command(cmd.decode()), payload=json.loads(payload.decode())) diff --git a/src/core/module.py b/src/core/module.py index 6e0d29e..db8e08e 100644 --- a/src/core/module.py +++ b/src/core/module.py @@ -1,12 +1,12 @@ import threading from abc import ABC, abstractmethod from multiprocessing.synchronize import Event -from typing import Callable, Dict, final, Any, Mapping +from typing import Any, Callable, Dict, Mapping, final import zmq -from src.tools.logger import logging from src.core.events import ModuleEvent +from src.tools.logger import logging class Module(ABC): diff --git a/src/core/shell.py b/src/core/shell.py index 6473357..5d01985 100644 --- a/src/core/shell.py +++ b/src/core/shell.py @@ -1,7 +1,7 @@ import cmd from src.core.huri import HuRI -from src.core.zmq.control_channel import Command +from src.core.events import CommandEvent, Command class RobotShell(cmd.Cmd): @@ -14,18 +14,18 @@ def __init__(self, huri: HuRI) -> None: def do_status(self, arg) -> None: "Display modules and router status." - self.huri.router.send_commands(Command("STATUS", [])) + self.huri.router.send_commands(Command.STATUS) def do_start(self, arg) -> None: "Start a module." - self.huri.router.send_commands(Command("START", [arg.strip()])) + self.huri.router.send_commands(Command.START, arg) def do_stop(self, arg) -> None: "Stop a module." - self.huri.router.send_commands(Command("STOP", [arg.strip()])) + self.huri.router.send_commands(Command.STOP, arg) def do_exit(self, arg) -> None: "Exit HuRi." - self.huri.router.send_commands(Command("EXIT", [])) + self.huri.router.send_commands(Command.EXIT) print("Bye !") return True diff --git a/src/core/zmq/control_channel.py b/src/core/zmq/control_channel.py index 89ef839..b1a110d 100644 --- a/src/core/zmq/control_channel.py +++ b/src/core/zmq/control_channel.py @@ -1,39 +1,39 @@ import json import uuid from dataclasses import asdict, dataclass -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, Mapping import zmq +from src.core.events import Command, CommandEvent from src.tools.logger import logging, setup_logger +# @dataclass +# class Command: +# cmd: str # "STOP", "START", "STATUS", ... +# args: List[Any] # JSON-serializable arguments -@dataclass -class Command: - cmd: str # "STOP", "START", "STATUS", ... - args: List[Any] # JSON-serializable arguments +# def to_bytes(self) -> bytes: +# return json.dumps(asdict(self)).encode("utf-8") - def to_bytes(self) -> bytes: - return json.dumps(asdict(self)).encode("utf-8") +# @staticmethod +# def from_bytes(data: bytes) -> "Command": +# obj = json.loads(data.decode("utf-8")) +# return Command(**obj) - @staticmethod - def from_bytes(data: bytes) -> "Command": - obj = json.loads(data.decode("utf-8")) - return Command(**obj) +# @dataclass +# class Result: +# success: bool +# result: List[Any] -@dataclass -class Result: - success: bool - result: List[Any] +# def to_bytes(self) -> bytes: +# return json.dumps(asdict(self)).encode("utf-8") - def to_bytes(self) -> bytes: - return json.dumps(asdict(self)).encode("utf-8") - - @staticmethod - def from_bytes(data: bytes) -> "Command": - obj = json.loads(data.decode("utf-8")) - return Result(**obj) +# @staticmethod +# def from_bytes(data: bytes) -> "Command": +# obj = json.loads(data.decode("utf-8")) +# return Result(**obj) class Router: @@ -43,9 +43,8 @@ def __init__( port: int, logger: Optional[logging.Logger] = setup_logger("Router"), ): - self.ctx = zmq.Context.instance() - self.router = self.ctx.socket(zmq.ROUTER) + self.router: zmq.Socket[bytes] = self.ctx.socket(zmq.ROUTER) self.hostname = hostname self.port = port @@ -53,26 +52,26 @@ def __init__( self.dealers: Dict[bytes, bool] = {} + def register_dealer(self, auth: str, name: str, config: Dict[str, Any]) -> None: + if auth != "oui": + return + + self.dealers[name] = config + self.logger.info(f"Dealer registered: {name}") + def start(self): self.router.bind(f"tcp://{self.hostname}:{self.port}") self.logger.info("Router started") try: while True: - identity, *frames = self.router.recv_multipart() + identity, *data = self.router.recv_multipart() + self.logger.warning(data) + command = CommandEvent.deserialize(data) - if not frames: - continue + if command.cmd == Command.REGISTER: + self.register_dealer(**command.payload) - command = frames[0] - - if command == b"REGISTER": - self.dealers[identity] = True - self.logger.info(f"Dealer registered: {identity}") - - elif command == b"RESULT": - payload = frames[1] if len(frames) > 1 else b"" - self.logger.info(f"Result from {identity}: {payload.decode()}") except Exception as e: self.logger.exception(e) pass @@ -82,15 +81,19 @@ def start(self): def stop(self) -> None: self.router.close() - def send_command(self, dealer_id: bytes, command: Command) -> None: - if dealer_id not in self.dealers: + def send_command( + self, dealer_name: str, command: Command, **kwargs: Mapping[str, Any] + ) -> None: + if dealer_name not in self.dealers: raise ValueError("Dealer not registered") - self.router.send_multipart([dealer_id, b"COMMAND", command.to_bytes()]) + event = CommandEvent(cmd=command, payload=kwargs) + self.router.send_multipart(event.serialize()) - def send_commands(self, command: Command) -> None: - for dealer_id, _ in self.dealers.items(): - self.send_command(dealer_id, command) + def send_commands(self, command: Command, **kwargs: Mapping[str, Any]) -> None: + for dealer_name, _ in self.dealers.items(): + self.logger.info(f"Sending Command to: {dealer_name}") + self.send_command(dealer_name, command, **kwargs) class Dealer: @@ -103,41 +106,49 @@ def __init__( identity: Optional[str] = None, ): self.ctx = zmq.Context.instance() - self.dealer = self.ctx.socket(zmq.DEALER) + self.dealer: zmq.Socket[bytes] = self.ctx.socket(zmq.DEALER) self.hostname = hostname self.port = port self.executor = executor - self.identity = (identity or str(uuid.uuid4())).encode() # TODO agent name + self.identity = identity or str(uuid.uuid4()) # TODO agent name self.logger = logger or logging.getLogger(f"Dealer {self.identity}") def start(self): self.dealer.connect(f"tcp://{self.hostname}:{self.port}") - self.dealer.setsockopt(zmq.IDENTITY, self.identity) + self.dealer.setsockopt(zmq.IDENTITY, self.identity.encode()) self.logger.info(f"Dealer started: {self.identity}") try: - self.dealer.send(b"REGISTER") + register = CommandEvent( + cmd=Command.REGISTER, + payload={ + "auth": "oui", + "name": self.identity, + "config": {"none": None}, + }, + ) + self.dealer.send_multipart(register.serialize()) while True: - frames = self.dealer.recv_multipart() - - command = frames[0] + # self.dealer. + self.logger.info("received nothing still") + data = self.dealer.recv_multipart() + self.logger.info("received") + command = CommandEvent.deserialize(data) - if command == b"COMMAND": - self.logger.info("received command") - payload = frames[1] if len(frames) > 1 else b"" - result = self.execute(payload) + self.logger.info("received command") + result = self.execute(command) - self.dealer.send_multipart([b"RESULT", result]) + # self.dealer.send_multipart([b"RESULT", result]) except Exception as e: self.logger.exception(e) finally: self.dealer.close() - def execute(self, command: Command) -> bytes: + def execute(self, command: CommandEvent) -> bytes: """ Execute command sent by Router """ diff --git a/src/core/zmq/event_proxy.py b/src/core/zmq/event_proxy.py index 66d2b4c..ba8ea9d 100644 --- a/src/core/zmq/event_proxy.py +++ b/src/core/zmq/event_proxy.py @@ -1,10 +1,10 @@ from dataclasses import dataclass -from typing import Optional, Mapping, Any +from typing import Any, Mapping, Optional import zmq -from src.tools.logger import logging, setup_logger from src.core.events import ModuleEvent +from src.tools.logger import logging, setup_logger @dataclass @@ -24,8 +24,8 @@ def __init__( ): self.ctx = zmq.Context.instance() - self.xpub = self.ctx.socket(zmq.XPUB) - self.xsub = self.ctx.socket(zmq.XSUB) + self.xpub: zmq.Socket[bytes] = self.ctx.socket(zmq.XPUB) + self.xsub: zmq.Socket[bytes] = self.ctx.socket(zmq.XSUB) self.hostname = hostname self.connect_hostname = connect_hostname diff --git a/src/launch_agent.py b/src/launch_agent.py index efc63c4..1d512ee 100644 --- a/src/launch_agent.py +++ b/src/launch_agent.py @@ -34,7 +34,7 @@ def main() -> None: try: agent.run() except KeyboardInterrupt: - agent.stop_all() + agent.exit() except Exception as e: logging.getLogger(__name__).error(e) From 8438ee903c5fa78837a975a933b1ea792d63f255 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Thu, 15 Jan 2026 10:42:28 +0100 Subject: [PATCH 05/65] evol(control_channel): dealer register with auth(wip) then start --- src/core/events.py | 2 +- src/core/zmq/control_channel.py | 212 +++++++++++++++++++------------- 2 files changed, 127 insertions(+), 87 deletions(-) diff --git a/src/core/events.py b/src/core/events.py index 38051b6..a510e9d 100644 --- a/src/core/events.py +++ b/src/core/events.py @@ -30,6 +30,7 @@ def deserialize(cls, raw: List[bytes]): class Command(Enum): REGISTER = "REGISTER" + AUTH_OK = "AUTH_OK" START = "START" STOP = "STOP" START_MODULE = "START_MODULE" @@ -57,6 +58,5 @@ def serialize(self) -> Sequence: @classmethod def deserialize(cls, raw: List[bytes]): - print(raw) cmd, payload = raw return cls(cmd=Command(cmd.decode()), payload=json.loads(payload.decode())) diff --git a/src/core/zmq/control_channel.py b/src/core/zmq/control_channel.py index b1a110d..5e078f1 100644 --- a/src/core/zmq/control_channel.py +++ b/src/core/zmq/control_channel.py @@ -1,40 +1,14 @@ import json +import threading import uuid from dataclasses import asdict, dataclass -from typing import Any, Callable, Dict, List, Optional, Mapping +from typing import Any, Callable, Dict, List, Mapping, Optional import zmq from src.core.events import Command, CommandEvent from src.tools.logger import logging, setup_logger -# @dataclass -# class Command: -# cmd: str # "STOP", "START", "STATUS", ... -# args: List[Any] # JSON-serializable arguments - -# def to_bytes(self) -> bytes: -# return json.dumps(asdict(self)).encode("utf-8") - -# @staticmethod -# def from_bytes(data: bytes) -> "Command": -# obj = json.loads(data.decode("utf-8")) -# return Command(**obj) - - -# @dataclass -# class Result: -# success: bool -# result: List[Any] - -# def to_bytes(self) -> bytes: -# return json.dumps(asdict(self)).encode("utf-8") - -# @staticmethod -# def from_bytes(data: bytes) -> "Command": -# obj = json.loads(data.decode("utf-8")) -# return Result(**obj) - class Router: def __init__( @@ -48,60 +22,104 @@ def __init__( self.hostname = hostname self.port = port - self.logger = logger or logging.getLogger(__name__) + self._stop_event = None + self._poll_thread = None + self._started = False self.dealers: Dict[bytes, bool] = {} - def register_dealer(self, auth: str, name: str, config: Dict[str, Any]) -> None: + self.logger = logger or logging.getLogger(__name__) + + def _register_dealer( + self, identity: bytes, auth: str, name: str, config: Dict[str, Any] + ) -> None: if auth != "oui": return - self.dealers[name] = config - self.logger.info(f"Dealer registered: {name}") + self.dealers[identity] = config + self.logger.info(f"Dealer registered: {identity}") - def start(self): - self.router.bind(f"tcp://{self.hostname}:{self.port}") - self.logger.info("Router started") + self.send_command(identity, Command.AUTH_OK) + self.send_command(identity, Command.START) + + def _poll(self) -> None: # todo poller + """ + Start a blocking poller loop. + call self.stop() to stop. + """ - try: - while True: + while not self._stop_event.is_set(): + try: identity, *data = self.router.recv_multipart() - self.logger.warning(data) command = CommandEvent.deserialize(data) if command.cmd == Command.REGISTER: - self.register_dealer(**command.payload) + self._register_dealer(identity, **command.payload) + else: + raise Exception(f"Dealer {identity} sent {command.cmd}") + except zmq.Again: + continue + except Exception as e: + self.logger.warning(e, exc_info=True) + + def start(self) -> None: + """ + Bind to endpoint. + Launch a poll loop thread. + """ + if self._started is True: + raise Exception("already started") + + self.router.bind(f"tcp://{self.hostname}:{self.port}") + self.router.setsockopt(zmq.RCVTIMEO, 1000) + self.logger.info("started") - except Exception as e: - self.logger.exception(e) - pass - finally: - self.router.close() + self._stop_event = threading.Event() + self._poll_thread = threading.Thread(target=self._poll) + self._poll_thread.start() + + self._started = True def stop(self) -> None: - self.router.close() + if self._started is False: + raise Exception("not started") + + self._stop_event.set() + self._poll_thread.join(2.0) + + self.router.close(linger=0) + self._stop_event = None + self._poll_thread = None + + self._started = False def send_command( - self, dealer_name: str, command: Command, **kwargs: Mapping[str, Any] + self, dealer_identity: str, command: Command, **kwargs: Mapping[str, Any] ) -> None: - if dealer_name not in self.dealers: - raise ValueError("Dealer not registered") + if self._started is False: + raise Exception("not started") + + if dealer_identity not in self.dealers: + raise ValueError(f"Dealer {dealer_identity} not registered") event = CommandEvent(cmd=command, payload=kwargs) - self.router.send_multipart(event.serialize()) + self.logger.info(f"Sending Command {command} to: {dealer_identity}") + self.router.send_multipart([dealer_identity] + event.serialize()) def send_commands(self, command: Command, **kwargs: Mapping[str, Any]) -> None: - for dealer_name, _ in self.dealers.items(): - self.logger.info(f"Sending Command to: {dealer_name}") - self.send_command(dealer_name, command, **kwargs) + if self._started is False: + raise Exception("not started") + for dealer_identity, _ in self.dealers.items(): + self.send_command(dealer_identity, command, **kwargs) -class Dealer: + +class Dealer: # todo heartbeat def __init__( self, hostname: str, port: int, - executor: Callable[[Command], bool], + handler: Callable[[Command], bool], logger: Optional[logging.Logger] = None, identity: Optional[str] = None, ): @@ -111,52 +129,74 @@ def __init__( self.hostname = hostname self.port = port - self.executor = executor + self.handler = handler self.identity = identity or str(uuid.uuid4()) # TODO agent name + self._stop_event = None + self._poll_thread = None + self._started = False + self.logger = logger or logging.getLogger(f"Dealer {self.identity}") - def start(self): - self.dealer.connect(f"tcp://{self.hostname}:{self.port}") - self.dealer.setsockopt(zmq.IDENTITY, self.identity.encode()) - self.logger.info(f"Dealer started: {self.identity}") + def _poll(self) -> None: + """ + Start a blocking poller loop. + call self.stop() to stop. + """ - try: - register = CommandEvent( - cmd=Command.REGISTER, - payload={ - "auth": "oui", - "name": self.identity, - "config": {"none": None}, - }, - ) - self.dealer.send_multipart(register.serialize()) - - while True: - # self.dealer. - self.logger.info("received nothing still") + while not self._stop_event.is_set(): + try: data = self.dealer.recv_multipart() - self.logger.info("received") command = CommandEvent.deserialize(data) - self.logger.info("received command") - result = self.execute(command) + self.logger.info(f"Received Command {command.cmd}") + result = self.handler(command) # self.dealer.send_multipart([b"RESULT", result]) - except Exception as e: - self.logger.exception(e) - finally: - self.dealer.close() + except zmq.Again: + continue + except Exception as e: + self.logger.exception(e) - def execute(self, command: CommandEvent) -> bytes: + def start(self) -> None: """ - Execute command sent by Router + Connect to endpoint. + Launch a poll loop thread. + Send Register command (wip). """ - self.executor(command) + if self._started is True: + raise Exception("already started") + + self.dealer.connect(f"tcp://{self.hostname}:{self.port}") + self.dealer.setsockopt(zmq.IDENTITY, b"name") + self.dealer.setsockopt(zmq.RCVTIMEO, 1000) + self.logger.info(f"Dealer started: {self.identity}") + + self._stop_event = threading.Event() + self._poll_thread = threading.Thread(target=self._poll) + self._poll_thread.start() - # Example execution - result = f"Executed: {command.cmd}" - return result.encode() + register = CommandEvent( + cmd=Command.REGISTER, + payload={ + "auth": "oui", + "name": self.identity, + "config": {"none": None}, + }, + ) + self.dealer.send_multipart(register.serialize()) + + self._started = True def stop(self) -> None: + if self._started is False: + raise Exception("not started") + + self._stop_event.set() + self._poll_thread.join(2.0) + self.dealer.close(linger=0) + self._stop_event = None + self._poll_thread = None + + self._started = False From 29079c1422cdf49ed0e0d81c942ac84a87ae42c8 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Thu, 15 Jan 2026 10:43:53 +0100 Subject: [PATCH 06/65] evol(agent): thread is now inside zmq classes --- src/core/agent.py | 81 ++++++++++++++++++++++--------------- src/core/huri.py | 47 +++++++++------------ src/core/zmq/event_proxy.py | 34 ++++++++++++++-- src/core/zmq/log_channel.py | 47 +++++++++++++++++---- 4 files changed, 139 insertions(+), 70 deletions(-) diff --git a/src/core/agent.py b/src/core/agent.py index 32d5f51..33eb233 100644 --- a/src/core/agent.py +++ b/src/core/agent.py @@ -4,7 +4,8 @@ from dataclasses import dataclass from multiprocessing.synchronize import Event from typing import Any, Dict, Mapping - +import sys +import os from src.core.events import Command, CommandEvent from src.modules.factory import ModuleFactory @@ -87,7 +88,7 @@ def __init__(self, config: AgentConfig) -> None: self.processes: Dict[str, mp.Process] = {} self.stop_events: Dict[str, Event] = {} - self.threads: Dict[str, threading.Thread] = {} + self.stop_event: threading.Event = threading.Event() self.log_pusher = LogPusher( hostname=config.huri.hostname, port=config.huri.log_puller.port @@ -96,9 +97,10 @@ def __init__(self, config: AgentConfig) -> None: self.dealer = Dealer( hostname=config.huri.hostname, port=config.huri.router.port, - executor=self._command_handler, + handler=self._command_handler, logger=setup_logger("Dealer", log_queue=self.log_pusher.log_queue), ) + self.auth_ok = threading.Event() self.up_proxy = EventProxy( hostname=config.hostname, @@ -119,15 +121,19 @@ def __init__(self, config: AgentConfig) -> None: f"Agent {self.dealer.identity}", log_queue=self.log_pusher.log_queue ) - def _command_handler(self, command: CommandEvent) -> bool: + def _command_handler(self, command: CommandEvent) -> bool: # todo data race ? match command.cmd: + case Command.AUTH_OK: + self.auth_ok.set() + return True case Command.START: - for name in self.modules: + for name in list(self.modules.keys()): self.start_module(name) - pass + return True case Command.STOP: for name in list(self.processes.keys()): self.stop_module(name) + return True case Command.START_MODULE: return self.start_module(**command.payload) case Command.STOP_MODULE: @@ -135,8 +141,10 @@ def _command_handler(self, command: CommandEvent) -> bool: case Command.STATUS: return self.status() case Command.EXIT: - self.exit() - pass + "Stop run loop" + self.stop_event.set() + os.close(sys.stdin.fileno()) + return True case _: return False # todo log @@ -214,18 +222,17 @@ def stop_module(self, name) -> None: del self.stop_events[name] self.log_pusher.level_filter.del_level(name) - def exit(self) -> None: + def stop(self) -> None: for name in list(self.processes.keys()): self.stop_module(name) self.dealer.stop() self.up_proxy.stop() self.down_proxy.stop() - for name, thread in self.threads.items(): - self.logger.info(f"Stopping {name} thread...") - thread.join(timeout=5) - self.logger.info(f"{name} thread stopped") - self.log_pusher.level_filter.del_level(name) + + self.log_pusher.level_filter.del_level("Dealer") + self.log_pusher.level_filter.del_level("UpProxy") + self.log_pusher.level_filter.del_level("DownProxy") self.log_pusher.stop() print("Fully stopped") @@ -251,36 +258,46 @@ def set_log_level(self, name: str, level: int) -> None: def set_log_levels(self, level: int) -> None: self.log_pusher.level_filter.set_levels(level) - def _connect_to_huri(self) -> None: - self.log_pusher.level_filter.add_level("Dealer") - self.threads["Dealer"] = threading.Thread(target=self.dealer.start) - self.threads["Dealer"].start() - def _start_event_proxies(self) -> None: """Used to handle inter-module communication, though events""" self.log_pusher.level_filter.add_level("UpProxy") self.log_pusher.level_filter.add_level("DownProxy") - self.threads["UpProxy"] = threading.Thread( - target=self.up_proxy.start, args=[True, False] - ) - self.threads["DownProxy"] = threading.Thread( - target=self.down_proxy.start, args=[False, True] - ) - self.threads["UpProxy"].start() - self.threads["DownProxy"].start() + self.up_proxy.start(True, False) + self.down_proxy.start(False, True) def run(self) -> None: - """Start event router and modules""" # TODO config (also logs levels) + """ + Start Dealer and check auth. + Then start EventProxies and LogPusher. + Then loop over input() to send input as Event. + Then, when exit is requested, call stop() + """ # TODO config (also logs levels) try: + self.log_pusher.level_filter.add_level("Dealer") + self.dealer.start() + + if self.auth_ok.wait(5.0) is False: + raise Exception("not authentificated") + self.log_pusher.start() - self._connect_to_huri() self._start_event_proxies() except Exception as e: self.logger.error(e) return - while True: - data = input() - self.down_proxy.publish("std.in", data=data) + while not self.stop_event.is_set(): + try: + data = "" + data = input() + except EOFError: + self.logger.info("pressed EOF") + print("^D") + + if data == "": + self.down_proxy.publish("std.out") + else: + self.down_proxy.publish("std.in", data=data) + + self.stop() diff --git a/src/core/huri.py b/src/core/huri.py index 18f52f3..ab84fc4 100644 --- a/src/core/huri.py +++ b/src/core/huri.py @@ -1,7 +1,7 @@ import sys import threading from dataclasses import dataclass -from typing import Dict +from time import sleep from src.tools.logger import setup_logger @@ -53,45 +53,38 @@ def __init__(self, config: HuriConfig) -> None: ) self.log_channel = LogPuller(config.hostname, config.log_puller.port) - self.threads: Dict[str, threading.Thread] = {} + self.stop_event = threading.Event() self.logger = setup_logger("HuRI") - def _start_router(self) -> None: - """Used to handle Agent registration and control""" - self.threads["Router"] = threading.Thread(target=self.router.start) - self.threads["Router"].start() - - def _start_event_proxy(self) -> None: - """Used to handle inter-module communication, though events""" - self.threads["EventProxy"] = threading.Thread( - target=self.event_proxy.start, args=[False, False] - ) - self.threads["EventProxy"].start() - - def _start_log_channel(self) -> None: - """Used to handle Agent registration and control""" - self.threads["LogChannel"] = threading.Thread(target=self.log_channel.start) - self.threads["LogChannel"].start() - def run(self) -> None: - self._start_log_channel() - self._start_router() - self._start_event_proxy() + """ + Start LogPuller. + Start Router. + Start EventProxy. + Then loop over RobotShell.cmdloop() to send input as commandst. + Then, when exit is requested, call stop() + """ + + "Used to handle log filtering and displaying" + self.log_channel.start() + "Used to handle Agent registration and control" + self.router.start() + "Used to handle inter-module communication, though events" + self.event_proxy.start(False, False) if not sys.stdin.isatty(): - threading.Event().wait() + self.stop_event.wait() return from src.core.shell import RobotShell RobotShell(self).cmdloop() + self.stop() + def stop(self) -> None: self.router.stop() self.event_proxy.stop() self.log_channel.stop() - for name, thread in self.threads.items(): - self.logger.info(f"Stopping {name} thread...") - thread.join(timeout=5) - self.logger.info(f"{name} thread stopped") + print("Fully stopped") diff --git a/src/core/zmq/event_proxy.py b/src/core/zmq/event_proxy.py index ba8ea9d..0ac2514 100644 --- a/src/core/zmq/event_proxy.py +++ b/src/core/zmq/event_proxy.py @@ -1,3 +1,4 @@ +import threading from dataclasses import dataclass from typing import Any, Mapping, Optional @@ -32,9 +33,18 @@ def __init__( self.xpub_port = xpub_port self.xsub_port = xsub_port + self._started: bool = False + self.logger = logger or logging.getLogger(__name__) def start(self, xpub_connect: bool, xsub_connect: bool): + """ + Connect to endpoint. + Launch a proxy thread. + """ + if self._started is True: + raise Exception("already started") + if xpub_connect: self.xpub.connect(f"tcp://{self.connect_hostname}:{self.xpub_port}") else: @@ -44,21 +54,39 @@ def start(self, xpub_connect: bool, xsub_connect: bool): else: self.xsub.bind(f"tcp://{self.hostname}:{self.xsub_port}") + self.logger.info("Correctly initialized, starting proxy") + + self._proxy_thread = threading.Thread(target=self._proxy) + self._proxy_thread.start() + + self._started = True + + def _proxy(self) -> None: try: - self.logger.info("Correctly initialized, starting proxy") - zmq.proxy(self.xsub, self.xpub) + zmq.proxy(self.xsub, self.xpub) # todo capture to stop except Exception as e: self.logger.error(e) def stop(self) -> None: - self.xsub.close(linger=0) + if self._started is False: + raise Exception("not started") + + self.xsub.close(linger=0) # todo capture self.xpub.close(linger=0) + self._proxy_thread.join(2.0) + self._proxy_thread = None + + self._started = False + def publish( self, topic: str, **kwargs: Mapping[str, Any], ) -> None: + if self._started is False: + raise Exception("not started") + event = ModuleEvent(topic=topic, payload=kwargs) self.xpub.send_multipart(event.serialize()) self.logger.info(f"Publish: {topic} str") diff --git a/src/core/zmq/log_channel.py b/src/core/zmq/log_channel.py index 6eb2a8e..a9bc8d3 100644 --- a/src/core/zmq/log_channel.py +++ b/src/core/zmq/log_channel.py @@ -1,4 +1,5 @@ import json +import threading import time from typing import Any, Dict, Optional @@ -66,24 +67,54 @@ def __init__( logger: Optional[logging.Logger] = setup_logger("LogPuller"), ) -> None: self.ctx = zmq.Context.instance() - self.pull = self.ctx.socket(zmq.PULL) + self.pull: zmq.Socket[bytes] = self.ctx.socket(zmq.PULL) self.hostname = hostname self.port = port + self._stop_event = None + self._poll_thread = None + self._started = False + self.logger = logger or logging.getLogger(__name__) + def _poll(self) -> None: + while not self._stop_event.is_set(): + try: + payload = self.pull.recv() + + self.logger.handle(dict_to_record(json.loads(payload.decode()))) + except zmq.Again: + continue + except Exception as e: + self.logger.exception(e) + def start(self) -> None: - self.pull.bind(f"tcp://{self.hostname}:{self.port}") + if self._started is True: + raise Exception("already started") + self.pull.bind(f"tcp://{self.hostname}:{self.port}") + self.pull.setsockopt(zmq.RCVTIMEO, 1000) self.logger.info("started") - while True: - payload = self.pull.recv() - self.logger.handle(dict_to_record(json.loads(payload.decode()))) + self._stop_event = threading.Event() + self._poll_thread = threading.Thread(target=self._poll) + self._poll_thread.start() + + self._started = True def stop(self) -> None: - self.pull.close() + if self._started is False: + raise Exception("not started") + + self._stop_event.set() + self._poll_thread.join(2.0) + + self.pull.close(linger=0) + self._stop_event = None + self._poll_thread = None + + self._started = False class LogPusher: @@ -95,7 +126,7 @@ def __init__( ): super().__init__() self.ctx = zmq.Context.instance() - self.socket = self.ctx.socket(zmq.PUSH) + self.socket: zmq.Socket[bytes] = self.ctx.socket(zmq.PUSH) self.hostname = hostname self.port = port @@ -135,7 +166,7 @@ def start(self) -> None: self.log_handler.start() self.log_listener.start() - def stop(self): + def stop(self): # todo _started self.logger.info("stopping") time.sleep(0.2) self.log_listener.stop() From 31c8fb1bd16f4c023f42a887c4ed7526b0bdbf52 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Thu, 15 Jan 2026 10:44:35 +0100 Subject: [PATCH 07/65] fix(core): agent and huri now exit cleanly --- src/core/shell.py | 3 ++- src/launch_agent.py | 3 ++- src/launch_huri.py | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/shell.py b/src/core/shell.py index 5d01985..bf2781b 100644 --- a/src/core/shell.py +++ b/src/core/shell.py @@ -1,7 +1,7 @@ import cmd +from src.core.events import Command, CommandEvent from src.core.huri import HuRI -from src.core.events import CommandEvent, Command class RobotShell(cmd.Cmd): @@ -23,6 +23,7 @@ def do_start(self, arg) -> None: def do_stop(self, arg) -> None: "Stop a module." self.huri.router.send_commands(Command.STOP, arg) + self.huri.stop_event.set() def do_exit(self, arg) -> None: "Exit HuRi." diff --git a/src/launch_agent.py b/src/launch_agent.py index 1d512ee..dd91427 100644 --- a/src/launch_agent.py +++ b/src/launch_agent.py @@ -34,9 +34,10 @@ def main() -> None: try: agent.run() except KeyboardInterrupt: - agent.exit() + agent.stop() except Exception as e: logging.getLogger(__name__).error(e) + agent.stop() if __name__ == "__main__": diff --git a/src/launch_huri.py b/src/launch_huri.py index b4f9fd0..b1f9485 100644 --- a/src/launch_huri.py +++ b/src/launch_huri.py @@ -37,6 +37,7 @@ def main() -> None: huri.stop() except Exception as e: logging.getLogger(__name__).error(e) + huri.stop() if __name__ == "__main__": From 3ad3368096663e5aea7c02ce0955f4d68811cbe0 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Thu, 15 Jan 2026 12:17:30 +0100 Subject: [PATCH 08/65] evol(events): update events naming --- src/core/events.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/core/events.py b/src/core/events.py index a510e9d..11f8aa1 100644 --- a/src/core/events.py +++ b/src/core/events.py @@ -28,35 +28,37 @@ def deserialize(cls, raw: List[bytes]): return cls(topic=topic.decode(), payload=json.loads(payload.decode())) -class Command(Enum): - REGISTER = "REGISTER" - AUTH_OK = "AUTH_OK" - START = "START" - STOP = "STOP" - START_MODULE = "START_MODULE" - STOP_MODULE = "STOP_MODULE" - STATUS = "STATUS" - EXIT = "EXIT" +class Control(Enum): + # Agent -> HuRI + REGISTER = "REGISTER" # send auth + agent config + HEARTBEAT = "HEARTBEAT" # send agent heartbeat + modified config + # HuRI -> Agents + AUTH_OK = "AUTH_OK" # send huri config (after) + START = "START" # start all modules + STOP = "STOP" # stop all modules + START_MODULE = "START_MODULE" # start specific modules + STOP_MODULE = "STOP_MODULE" # stop specific modules + EXIT = "EXIT" # exit agent @dataclass -class CommandEvent: - cmd: Command +class ControlEvent: + ctrl: Control payload: Mapping[str, Any] @classmethod def from_dict(cls, raw: Dict): - return cls(cmd=Command(raw["topic"]), payload=raw["payload"]) + return cls(ctrl=Control(raw["ctrl"]), payload=raw["payload"]) def serialize(self) -> Sequence: - print(self.cmd.value) + print(self.ctrl.value) return [ - self.cmd.value.encode(), + self.ctrl.value.encode(), json.dumps(self.payload).encode(), ] @classmethod def deserialize(cls, raw: List[bytes]): - cmd, payload = raw - return cls(cmd=Command(cmd.decode()), payload=json.loads(payload.decode())) + ctrl, payload = raw + return cls(ctrl=Control(ctrl.decode()), payload=json.loads(payload.decode())) From 97f949d2af638c8e38ae886b1879133da360e3e6 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Thu, 22 Jan 2026 10:14:33 +0100 Subject: [PATCH 09/65] wip(control_channel): huri can handle control --- src/core/agent.py | 20 ++++++------ src/core/events.py | 1 + src/core/huri.py | 54 ++++++++++++++++++++++++++++++--- src/core/zmq/control_channel.py | 45 +++++++++++++-------------- 4 files changed, 84 insertions(+), 36 deletions(-) diff --git a/src/core/agent.py b/src/core/agent.py index 33eb233..27382cf 100644 --- a/src/core/agent.py +++ b/src/core/agent.py @@ -6,7 +6,7 @@ from typing import Any, Dict, Mapping import sys import os -from src.core.events import Command, CommandEvent +from src.core.events import Control, ControlEvent from src.modules.factory import ModuleFactory from src.tools.logger import logging, setup_logger @@ -121,26 +121,26 @@ def __init__(self, config: AgentConfig) -> None: f"Agent {self.dealer.identity}", log_queue=self.log_pusher.log_queue ) - def _command_handler(self, command: CommandEvent) -> bool: # todo data race ? - match command.cmd: - case Command.AUTH_OK: + def _control_handler(self, command: ControlEvent) -> bool: # todo data race ? + match command.ctrl: + case Control.AUTH_OK: self.auth_ok.set() return True - case Command.START: + case Control.START: for name in list(self.modules.keys()): self.start_module(name) return True - case Command.STOP: + case Control.STOP: for name in list(self.processes.keys()): self.stop_module(name) return True - case Command.START_MODULE: + case Control.START_MODULE: return self.start_module(**command.payload) - case Command.STOP_MODULE: + case Control.STOP_MODULE: return self.stop_module(**command.payload) - case Command.STATUS: + case Control.STATUS: return self.status() - case Command.EXIT: + case Control.EXIT: "Stop run loop" self.stop_event.set() os.close(sys.stdin.fileno()) diff --git a/src/core/events.py b/src/core/events.py index 11f8aa1..d3ca555 100644 --- a/src/core/events.py +++ b/src/core/events.py @@ -32,6 +32,7 @@ class Control(Enum): # Agent -> HuRI REGISTER = "REGISTER" # send auth + agent config HEARTBEAT = "HEARTBEAT" # send agent heartbeat + modified config + EXITED = "EXITED" # send exited info # HuRI -> Agents AUTH_OK = "AUTH_OK" # send huri config (after) START = "START" # start all modules diff --git a/src/core/huri.py b/src/core/huri.py index ab84fc4..664aab4 100644 --- a/src/core/huri.py +++ b/src/core/huri.py @@ -1,13 +1,14 @@ import sys import threading from dataclasses import dataclass -from time import sleep from src.tools.logger import setup_logger - +from typing import Dict from .zmq.control_channel import Router +from src.core.events import Control, ControlEvent from .zmq.event_proxy import EventProxy from .zmq.log_channel import LogPuller +from src.core.agent import AgentConfig @dataclass @@ -43,26 +44,71 @@ def from_dict(cls, raw: dict): ) +@dataclass +class AgentStatus: + update_time: int + config: AgentConfig + status: Dict[str, int] + + @classmethod + def from_dict(cls, raw: dict): + return cls( + update_time=raw["update_time"], + config=AgentConfig.from_dict(raw["config"]), + status=raw["status"], + ) + + class HuRI: """Wait for Agent to connect, handle module communication and Logging""" def __init__(self, config: HuriConfig) -> None: - self.router = Router(config.hostname, config.router.port) + self.config = config + + self.router = Router(config.hostname, config.router.port, self._control_handler) self.event_proxy = EventProxy( config.hostname, "", config.event_proxy.xpub, config.event_proxy.xsub ) self.log_channel = LogPuller(config.hostname, config.log_puller.port) + self.agents: Dict[bytes, AgentStatus] = {} + self.stop_event = threading.Event() self.logger = setup_logger("HuRI") + def _control_handler( + self, identity: bytes, event: ControlEvent + ) -> bool: # todo data race ? + match event.ctrl: + case Control.REGISTER: + if event.payload["auth"] != "oui": # todo wip + return False + self.router.dealers[identity] = True + self.agents[identity] = AgentStatus.from_dict(**event.payload["agent"]) + + self.router.send_control( + identity, Control.AUTH_OK, self.config + ) # todo send all config ? + self.router.send_control(identity, Control.START) + return True + case Control.HEARTBEAT: + # previous_config = self.router.dealers[identity] + # self.agents[identity] = todo + # todo AgentStatus concat + return True + case Control.EXITED: + del self.router.dealers[identity] + del self.agents[identity] + case _: + return False # todo log + def run(self) -> None: """ Start LogPuller. Start Router. Start EventProxy. - Then loop over RobotShell.cmdloop() to send input as commandst. + Then loop over RobotShell.cmdloop() to use HuRI commands. Then, when exit is requested, call stop() """ diff --git a/src/core/zmq/control_channel.py b/src/core/zmq/control_channel.py index 5e078f1..1f25719 100644 --- a/src/core/zmq/control_channel.py +++ b/src/core/zmq/control_channel.py @@ -6,7 +6,7 @@ import zmq -from src.core.events import Command, CommandEvent +from src.core.events import Control, ControlEvent from src.tools.logger import logging, setup_logger @@ -15,6 +15,7 @@ def __init__( self, hostname: str, port: int, + handler: Callable[[bytes, ControlEvent], bool], logger: Optional[logging.Logger] = setup_logger("Router"), ): self.ctx = zmq.Context.instance() @@ -22,6 +23,8 @@ def __init__( self.hostname = hostname self.port = port + self.handler = handler + self._stop_event = None self._poll_thread = None self._started = False @@ -30,7 +33,7 @@ def __init__( self.logger = logger or logging.getLogger(__name__) - def _register_dealer( + def register_dealer( self, identity: bytes, auth: str, name: str, config: Dict[str, Any] ) -> None: if auth != "oui": @@ -39,9 +42,6 @@ def _register_dealer( self.dealers[identity] = config self.logger.info(f"Dealer registered: {identity}") - self.send_command(identity, Command.AUTH_OK) - self.send_command(identity, Command.START) - def _poll(self) -> None: # todo poller """ Start a blocking poller loop. @@ -51,12 +51,13 @@ def _poll(self) -> None: # todo poller while not self._stop_event.is_set(): try: identity, *data = self.router.recv_multipart() - command = CommandEvent.deserialize(data) + event = ControlEvent.deserialize(data) - if command.cmd == Command.REGISTER: - self._register_dealer(identity, **command.payload) + if self.handler(identity, event) is False: + self.logger.warning("Could not execute control") else: - raise Exception(f"Dealer {identity} sent {command.cmd}") + self.logger.info("Control executed") + except zmq.Again: continue except Exception as e: @@ -93,8 +94,8 @@ def stop(self) -> None: self._started = False - def send_command( - self, dealer_identity: str, command: Command, **kwargs: Mapping[str, Any] + def send_control( + self, dealer_identity: str, Control: Control, **kwargs: Mapping[str, Any] ) -> None: if self._started is False: raise Exception("not started") @@ -102,16 +103,16 @@ def send_command( if dealer_identity not in self.dealers: raise ValueError(f"Dealer {dealer_identity} not registered") - event = CommandEvent(cmd=command, payload=kwargs) - self.logger.info(f"Sending Command {command} to: {dealer_identity}") + event = ControlEvent(cmd=Control, payload=kwargs) + self.logger.info(f"Sending Control {Control} to: {dealer_identity}") self.router.send_multipart([dealer_identity] + event.serialize()) - def send_commands(self, command: Command, **kwargs: Mapping[str, Any]) -> None: + def send_controls(self, Control: Control, **kwargs: Mapping[str, Any]) -> None: if self._started is False: raise Exception("not started") for dealer_identity, _ in self.dealers.items(): - self.send_command(dealer_identity, command, **kwargs) + self.send_control(dealer_identity, Control, **kwargs) class Dealer: # todo heartbeat @@ -119,7 +120,7 @@ def __init__( self, hostname: str, port: int, - handler: Callable[[Command], bool], + handler: Callable[[Control], bool], logger: Optional[logging.Logger] = None, identity: Optional[str] = None, ): @@ -147,10 +148,10 @@ def _poll(self) -> None: while not self._stop_event.is_set(): try: data = self.dealer.recv_multipart() - command = CommandEvent.deserialize(data) + Control = ControlEvent.deserialize(data) - self.logger.info(f"Received Command {command.cmd}") - result = self.handler(command) + self.logger.info(f"Received Control {Control.cmd}") + result = self.handler(Control) # self.dealer.send_multipart([b"RESULT", result]) except zmq.Again: @@ -162,7 +163,7 @@ def start(self) -> None: """ Connect to endpoint. Launch a poll loop thread. - Send Register command (wip). + Send Register Control (wip). """ if self._started is True: raise Exception("already started") @@ -176,8 +177,8 @@ def start(self) -> None: self._poll_thread = threading.Thread(target=self._poll) self._poll_thread.start() - register = CommandEvent( - cmd=Command.REGISTER, + register = ControlEvent( + cmd=Control.REGISTER, payload={ "auth": "oui", "name": self.identity, From 9c2e5645ea4f0898ae6022d9e3b35f87a4954287 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 16 Mar 2026 10:15:51 +0100 Subject: [PATCH 10/65] remove(archi): old deprecated archi files --- .gitignore | 3 + src/core/agent.py | 303 ---------------------------- src/core/shell.py | 32 --- src/core/zmq/__init__.py | 0 src/core/zmq/control_channel.py | 203 ------------------- src/core/zmq/event_proxy.py | 92 --------- src/core/zmq/log_channel.py | 173 ---------------- src/emotional_hub/input_analysis.py | 24 --- src/launch_agent.py | 44 ---- src/launch_huri.py | 44 ---- src/modules/factory.py | 33 --- src/modules/rag/__init__.py | 0 src/modules/rag/mode_controller.py | 37 ---- src/modules/rag/rag.py | 97 --------- src/modules/textIO/input.py | 17 -- src/modules/textIO/output.py | 10 - 16 files changed, 3 insertions(+), 1109 deletions(-) delete mode 100644 src/core/agent.py delete mode 100644 src/core/shell.py delete mode 100644 src/core/zmq/__init__.py delete mode 100644 src/core/zmq/control_channel.py delete mode 100644 src/core/zmq/event_proxy.py delete mode 100644 src/core/zmq/log_channel.py delete mode 100644 src/emotional_hub/input_analysis.py delete mode 100644 src/launch_agent.py delete mode 100644 src/launch_huri.py delete mode 100644 src/modules/factory.py delete mode 100644 src/modules/rag/__init__.py delete mode 100644 src/modules/rag/mode_controller.py delete mode 100644 src/modules/rag/rag.py delete mode 100644 src/modules/textIO/input.py delete mode 100644 src/modules/textIO/output.py diff --git a/.gitignore b/.gitignore index b953209..99ba63f 100644 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,6 @@ cython_debug/ # PyPI configuration file .pypirc + +# Others +.trash \ No newline at end of file diff --git a/src/core/agent.py b/src/core/agent.py deleted file mode 100644 index 27382cf..0000000 --- a/src/core/agent.py +++ /dev/null @@ -1,303 +0,0 @@ -import multiprocessing as mp -import signal -import threading -from dataclasses import dataclass -from multiprocessing.synchronize import Event -from typing import Any, Dict, Mapping -import sys -import os -from src.core.events import Control, ControlEvent - -from src.modules.factory import ModuleFactory -from src.tools.logger import logging, setup_logger - -from .huri import HuriConfig -from .zmq.control_channel import Dealer -from .zmq.event_proxy import EventProxy -from .zmq.log_channel import LogPusher - - -@dataclass -class ForwarderProxyConfig: - down_xsub: int - up_xpub: int - - @classmethod - def from_dict(cls, raw: dict): - return cls( - down_xsub=raw["down-xsub"], - up_xpub=raw["up-xpub"], - ) - - -@dataclass -class ModuleConfig: - name: str - args: Mapping[str, Any] - logging: int - - @classmethod - def from_dict(cls, raw: dict): - level = logging._nameToLevel.get( - raw.get("logging", "INFO"), - logging.INFO, - ) - return cls( - name=raw["name"], - args=raw.get("args", {}), - logging=level, - ) - - -@dataclass -class AgentConfig: - id: str - hostname: str - huri: HuriConfig - logging: int - forwarder_proxy: ForwarderProxyConfig - modules: Dict[str, ModuleConfig] - - @classmethod - def from_dict(cls, raw: dict): - level = logging._nameToLevel.get( - raw.get("logging", "INFO").upper(), - logging.INFO, - ) - modules = { - module_id: ModuleConfig.from_dict(mod_raw) - for module_id, mod_raw in raw.get("modules", {}).items() - } - return cls( - id=raw["id"], - hostname=raw["hostname"], - huri=HuriConfig.from_dict(raw["huri"]), - forwarder_proxy=ForwarderProxyConfig.from_dict(raw["forwarder-proxy"]), - logging=level, - modules=modules, - ) - - -class Agent: - """Control Modules and communication with HuRI""" - - def __init__(self, config: AgentConfig) -> None: - self.modules: Dict[str, ModuleConfig] = config.modules - self.config = config - - self.processes: Dict[str, mp.Process] = {} - self.stop_events: Dict[str, Event] = {} - - self.stop_event: threading.Event = threading.Event() - - self.log_pusher = LogPusher( - hostname=config.huri.hostname, port=config.huri.log_puller.port - ) - - self.dealer = Dealer( - hostname=config.huri.hostname, - port=config.huri.router.port, - handler=self._command_handler, - logger=setup_logger("Dealer", log_queue=self.log_pusher.log_queue), - ) - self.auth_ok = threading.Event() - - self.up_proxy = EventProxy( - hostname=config.hostname, - connect_hostname=config.huri.hostname, - xpub_port=config.huri.event_proxy.xsub, - xsub_port=config.forwarder_proxy.up_xpub, - logger=setup_logger("UpProxy", log_queue=self.log_pusher.log_queue), - ) - self.down_proxy = EventProxy( - hostname=config.hostname, - connect_hostname=config.huri.hostname, - xpub_port=config.forwarder_proxy.down_xsub, - xsub_port=config.huri.event_proxy.xpub, - logger=setup_logger("DownProxy", log_queue=self.log_pusher.log_queue), - ) - - self.logger = setup_logger( - f"Agent {self.dealer.identity}", log_queue=self.log_pusher.log_queue - ) - - def _control_handler(self, command: ControlEvent) -> bool: # todo data race ? - match command.ctrl: - case Control.AUTH_OK: - self.auth_ok.set() - return True - case Control.START: - for name in list(self.modules.keys()): - self.start_module(name) - return True - case Control.STOP: - for name in list(self.processes.keys()): - self.stop_module(name) - return True - case Control.START_MODULE: - return self.start_module(**command.payload) - case Control.STOP_MODULE: - return self.stop_module(**command.payload) - case Control.STATUS: - return self.status() - case Control.EXIT: - "Stop run loop" - self.stop_event.set() - os.close(sys.stdin.fileno()) - return True - case _: - return False # todo log - - @staticmethod - def _start_module( - name: str, - module_config: ModuleConfig, - agent_config: AgentConfig, - log_queue: mp.Queue, - stop_event: Event, - ) -> None: - """Helper function to start module in child process.""" - logger = setup_logger( - module_config.name, level=module_config.logging, log_queue=log_queue - ) - - module = ModuleFactory.create(name, module_config.args) - module.set_custom_logger(logger) - - def handle_sigint(signum, frame): - logger.info("Ctrl+C ignored in child module") - - signal.signal(signal.SIGINT, handle_sigint) - - module.start_module( - agent_config.hostname, - agent_config.forwarder_proxy.up_xpub, - agent_config.forwarder_proxy.down_xsub, - stop_event=stop_event, - ) - - def start_module(self, name) -> None: - """Check if module is registered and not already running, and start a child process.""" - if name not in self.modules: - self.logger.warning( - f"{name} is not in the registered Modules: {self.modules.keys()}" - ) - return - if name in self.processes: - self.logger.warning( - f"{name} is already running (PID={self.processes[name].pid})" - ) - return - - module_config = self.modules[name] - stop_event = mp.Event() - p = mp.Process( - target=self._start_module, - args=( - name, - module_config, - self.config, - self.log_pusher.log_queue, - stop_event, - ), - daemon=True, - ) - self.processes[name] = p - self.stop_events[name] = stop_event - self.log_pusher.level_filter.add_level(name) - - p.start() - self.logger.info(f"{name} ({module_config.name}) started (PID={p.pid})") - - def stop_module(self, name) -> None: - if name in self.processes: - self.logger.info(f"Stopping {name}...") - self.stop_events[name].set() - self.processes[name].join(timeout=5) - if self.processes[name].is_alive(): - self.logger.warning(f"{name} did not stop in time, killing") - self.processes[name].kill() - self.logger.info(f"{name} stopped") - del self.processes[name] - del self.stop_events[name] - self.log_pusher.level_filter.del_level(name) - - def stop(self) -> None: - for name in list(self.processes.keys()): - self.stop_module(name) - - self.dealer.stop() - self.up_proxy.stop() - self.down_proxy.stop() - - self.log_pusher.level_filter.del_level("Dealer") - self.log_pusher.level_filter.del_level("UpProxy") - self.log_pusher.level_filter.del_level("DownProxy") - - self.log_pusher.stop() - print("Fully stopped") - - def status(self) -> None: - """Print status of all modules and router.""" - print("=== Module Status ===") - for name in self.modules: - process = self.processes.get(name) - if process: - state = "alive" if process.is_alive() else "stopped" - print(f"- {name}: {state} (PID={process.pid})") - else: - print(f"- {name}: stopped") - print("=====================") - - def set_root_log_level(self, level: int) -> None: - self.log_pusher.level_filter.set_root_level(level) - - def set_log_level(self, name: str, level: int) -> None: - self.log_pusher.level_filter.set_level(name, level) - - def set_log_levels(self, level: int) -> None: - self.log_pusher.level_filter.set_levels(level) - - def _start_event_proxies(self) -> None: - """Used to handle inter-module communication, though events""" - self.log_pusher.level_filter.add_level("UpProxy") - self.log_pusher.level_filter.add_level("DownProxy") - - self.up_proxy.start(True, False) - self.down_proxy.start(False, True) - - def run(self) -> None: - """ - Start Dealer and check auth. - Then start EventProxies and LogPusher. - Then loop over input() to send input as Event. - Then, when exit is requested, call stop() - """ # TODO config (also logs levels) - - try: - self.log_pusher.level_filter.add_level("Dealer") - self.dealer.start() - - if self.auth_ok.wait(5.0) is False: - raise Exception("not authentificated") - - self.log_pusher.start() - self._start_event_proxies() - except Exception as e: - self.logger.error(e) - return - - while not self.stop_event.is_set(): - try: - data = "" - data = input() - except EOFError: - self.logger.info("pressed EOF") - print("^D") - - if data == "": - self.down_proxy.publish("std.out") - else: - self.down_proxy.publish("std.in", data=data) - - self.stop() diff --git a/src/core/shell.py b/src/core/shell.py deleted file mode 100644 index bf2781b..0000000 --- a/src/core/shell.py +++ /dev/null @@ -1,32 +0,0 @@ -import cmd - -from src.core.events import Command, CommandEvent -from src.core.huri import HuRI - - -class RobotShell(cmd.Cmd): - intro = "HuRI's shell. Type 'help' to see command's list." - prompt = "(HuRI) " - - def __init__(self, huri: HuRI) -> None: - super().__init__() - self.huri = huri - - def do_status(self, arg) -> None: - "Display modules and router status." - self.huri.router.send_commands(Command.STATUS) - - def do_start(self, arg) -> None: - "Start a module." - self.huri.router.send_commands(Command.START, arg) - - def do_stop(self, arg) -> None: - "Stop a module." - self.huri.router.send_commands(Command.STOP, arg) - self.huri.stop_event.set() - - def do_exit(self, arg) -> None: - "Exit HuRi." - self.huri.router.send_commands(Command.EXIT) - print("Bye !") - return True diff --git a/src/core/zmq/__init__.py b/src/core/zmq/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/core/zmq/control_channel.py b/src/core/zmq/control_channel.py deleted file mode 100644 index 1f25719..0000000 --- a/src/core/zmq/control_channel.py +++ /dev/null @@ -1,203 +0,0 @@ -import json -import threading -import uuid -from dataclasses import asdict, dataclass -from typing import Any, Callable, Dict, List, Mapping, Optional - -import zmq - -from src.core.events import Control, ControlEvent -from src.tools.logger import logging, setup_logger - - -class Router: - def __init__( - self, - hostname: str, - port: int, - handler: Callable[[bytes, ControlEvent], bool], - logger: Optional[logging.Logger] = setup_logger("Router"), - ): - self.ctx = zmq.Context.instance() - self.router: zmq.Socket[bytes] = self.ctx.socket(zmq.ROUTER) - self.hostname = hostname - self.port = port - - self.handler = handler - - self._stop_event = None - self._poll_thread = None - self._started = False - - self.dealers: Dict[bytes, bool] = {} - - self.logger = logger or logging.getLogger(__name__) - - def register_dealer( - self, identity: bytes, auth: str, name: str, config: Dict[str, Any] - ) -> None: - if auth != "oui": - return - - self.dealers[identity] = config - self.logger.info(f"Dealer registered: {identity}") - - def _poll(self) -> None: # todo poller - """ - Start a blocking poller loop. - call self.stop() to stop. - """ - - while not self._stop_event.is_set(): - try: - identity, *data = self.router.recv_multipart() - event = ControlEvent.deserialize(data) - - if self.handler(identity, event) is False: - self.logger.warning("Could not execute control") - else: - self.logger.info("Control executed") - - except zmq.Again: - continue - except Exception as e: - self.logger.warning(e, exc_info=True) - - def start(self) -> None: - """ - Bind to endpoint. - Launch a poll loop thread. - """ - if self._started is True: - raise Exception("already started") - - self.router.bind(f"tcp://{self.hostname}:{self.port}") - self.router.setsockopt(zmq.RCVTIMEO, 1000) - self.logger.info("started") - - self._stop_event = threading.Event() - self._poll_thread = threading.Thread(target=self._poll) - self._poll_thread.start() - - self._started = True - - def stop(self) -> None: - if self._started is False: - raise Exception("not started") - - self._stop_event.set() - self._poll_thread.join(2.0) - - self.router.close(linger=0) - self._stop_event = None - self._poll_thread = None - - self._started = False - - def send_control( - self, dealer_identity: str, Control: Control, **kwargs: Mapping[str, Any] - ) -> None: - if self._started is False: - raise Exception("not started") - - if dealer_identity not in self.dealers: - raise ValueError(f"Dealer {dealer_identity} not registered") - - event = ControlEvent(cmd=Control, payload=kwargs) - self.logger.info(f"Sending Control {Control} to: {dealer_identity}") - self.router.send_multipart([dealer_identity] + event.serialize()) - - def send_controls(self, Control: Control, **kwargs: Mapping[str, Any]) -> None: - if self._started is False: - raise Exception("not started") - - for dealer_identity, _ in self.dealers.items(): - self.send_control(dealer_identity, Control, **kwargs) - - -class Dealer: # todo heartbeat - def __init__( - self, - hostname: str, - port: int, - handler: Callable[[Control], bool], - logger: Optional[logging.Logger] = None, - identity: Optional[str] = None, - ): - self.ctx = zmq.Context.instance() - self.dealer: zmq.Socket[bytes] = self.ctx.socket(zmq.DEALER) - - self.hostname = hostname - self.port = port - - self.handler = handler - self.identity = identity or str(uuid.uuid4()) # TODO agent name - - self._stop_event = None - self._poll_thread = None - self._started = False - - self.logger = logger or logging.getLogger(f"Dealer {self.identity}") - - def _poll(self) -> None: - """ - Start a blocking poller loop. - call self.stop() to stop. - """ - - while not self._stop_event.is_set(): - try: - data = self.dealer.recv_multipart() - Control = ControlEvent.deserialize(data) - - self.logger.info(f"Received Control {Control.cmd}") - result = self.handler(Control) - - # self.dealer.send_multipart([b"RESULT", result]) - except zmq.Again: - continue - except Exception as e: - self.logger.exception(e) - - def start(self) -> None: - """ - Connect to endpoint. - Launch a poll loop thread. - Send Register Control (wip). - """ - if self._started is True: - raise Exception("already started") - - self.dealer.connect(f"tcp://{self.hostname}:{self.port}") - self.dealer.setsockopt(zmq.IDENTITY, b"name") - self.dealer.setsockopt(zmq.RCVTIMEO, 1000) - self.logger.info(f"Dealer started: {self.identity}") - - self._stop_event = threading.Event() - self._poll_thread = threading.Thread(target=self._poll) - self._poll_thread.start() - - register = ControlEvent( - cmd=Control.REGISTER, - payload={ - "auth": "oui", - "name": self.identity, - "config": {"none": None}, - }, - ) - self.dealer.send_multipart(register.serialize()) - - self._started = True - - def stop(self) -> None: - if self._started is False: - raise Exception("not started") - - self._stop_event.set() - self._poll_thread.join(2.0) - - self.dealer.close(linger=0) - self._stop_event = None - self._poll_thread = None - - self._started = False diff --git a/src/core/zmq/event_proxy.py b/src/core/zmq/event_proxy.py deleted file mode 100644 index 0ac2514..0000000 --- a/src/core/zmq/event_proxy.py +++ /dev/null @@ -1,92 +0,0 @@ -import threading -from dataclasses import dataclass -from typing import Any, Mapping, Optional - -import zmq - -from src.core.events import ModuleEvent -from src.tools.logger import logging, setup_logger - - -@dataclass -class ZMQEventPorts: - xpub: str - xsub: str - - -class EventProxy: - def __init__( - self, - hostname: str, - connect_hostname: str, - xpub_port: int, - xsub_port: int, - logger: Optional[logging.Logger] = setup_logger("EventProxy"), - ): - - self.ctx = zmq.Context.instance() - self.xpub: zmq.Socket[bytes] = self.ctx.socket(zmq.XPUB) - self.xsub: zmq.Socket[bytes] = self.ctx.socket(zmq.XSUB) - - self.hostname = hostname - self.connect_hostname = connect_hostname - self.xpub_port = xpub_port - self.xsub_port = xsub_port - - self._started: bool = False - - self.logger = logger or logging.getLogger(__name__) - - def start(self, xpub_connect: bool, xsub_connect: bool): - """ - Connect to endpoint. - Launch a proxy thread. - """ - if self._started is True: - raise Exception("already started") - - if xpub_connect: - self.xpub.connect(f"tcp://{self.connect_hostname}:{self.xpub_port}") - else: - self.xpub.bind(f"tcp://{self.hostname}:{self.xpub_port}") - if xsub_connect: - self.xsub.connect(f"tcp://{self.connect_hostname}:{self.xsub_port}") - else: - self.xsub.bind(f"tcp://{self.hostname}:{self.xsub_port}") - - self.logger.info("Correctly initialized, starting proxy") - - self._proxy_thread = threading.Thread(target=self._proxy) - self._proxy_thread.start() - - self._started = True - - def _proxy(self) -> None: - try: - zmq.proxy(self.xsub, self.xpub) # todo capture to stop - except Exception as e: - self.logger.error(e) - - def stop(self) -> None: - if self._started is False: - raise Exception("not started") - - self.xsub.close(linger=0) # todo capture - self.xpub.close(linger=0) - - self._proxy_thread.join(2.0) - self._proxy_thread = None - - self._started = False - - def publish( - self, - topic: str, - **kwargs: Mapping[str, Any], - ) -> None: - if self._started is False: - raise Exception("not started") - - event = ModuleEvent(topic=topic, payload=kwargs) - self.xpub.send_multipart(event.serialize()) - self.logger.info(f"Publish: {topic} str") diff --git a/src/core/zmq/log_channel.py b/src/core/zmq/log_channel.py deleted file mode 100644 index a9bc8d3..0000000 --- a/src/core/zmq/log_channel.py +++ /dev/null @@ -1,173 +0,0 @@ -import json -import threading -import time -from typing import Any, Dict, Optional - -import zmq - -from src.tools.logger import ( - LevelFilter, - QueueListener, - logging, - mp, - setup_log_listener, - setup_logger, -) - - -def record_to_dict(record: logging.LogRecord) -> Dict[str, Any]: - return { - "name": record.name, - "levelno": record.levelno, - "levelname": record.levelname, - "message": record.getMessage(), - "created": record.created, - "asctime": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(record.created)), - "process": record.process, - "processName": record.processName, - "thread": record.thread, - "threadName": record.threadName, - "module": record.module, - "filename": record.filename, - "pathname": record.pathname, - "lineno": record.lineno, - "funcName": record.funcName, - } - - -def dict_to_record(data: Dict[str, Any]) -> logging.LogRecord: - record = logging.LogRecord( - name=data["name"], - level=data["levelno"], - pathname=data["pathname"], - lineno=data["lineno"], - msg=data["message"], - args=(), - exc_info=None, - func=data["funcName"], - ) - - # Restore metadata - record.created = data["created"] - record.process = data["process"] - record.processName = data["processName"] - record.thread = data["thread"] - record.threadName = data["threadName"] - record.module = data["module"] - record.filename = data["filename"] - - return record - - -class LogPuller: - def __init__( - self, - hostname: str, - port: int, - logger: Optional[logging.Logger] = setup_logger("LogPuller"), - ) -> None: - self.ctx = zmq.Context.instance() - self.pull: zmq.Socket[bytes] = self.ctx.socket(zmq.PULL) - - self.hostname = hostname - self.port = port - - self._stop_event = None - self._poll_thread = None - self._started = False - - self.logger = logger or logging.getLogger(__name__) - - def _poll(self) -> None: - while not self._stop_event.is_set(): - try: - payload = self.pull.recv() - - self.logger.handle(dict_to_record(json.loads(payload.decode()))) - except zmq.Again: - continue - except Exception as e: - self.logger.exception(e) - - def start(self) -> None: - if self._started is True: - raise Exception("already started") - - self.pull.bind(f"tcp://{self.hostname}:{self.port}") - self.pull.setsockopt(zmq.RCVTIMEO, 1000) - self.logger.info("started") - - self._stop_event = threading.Event() - self._poll_thread = threading.Thread(target=self._poll) - self._poll_thread.start() - - self._started = True - - def stop(self) -> None: - if self._started is False: - raise Exception("not started") - - self._stop_event.set() - self._poll_thread.join(2.0) - - self.pull.close(linger=0) - self._stop_event = None - self._poll_thread = None - - self._started = False - - -class LogPusher: - class LogPusherHandler(logging.Handler): - def __init__( - self, - hostname: str, - port: int, - ): - super().__init__() - self.ctx = zmq.Context.instance() - self.socket: zmq.Socket[bytes] = self.ctx.socket(zmq.PUSH) - - self.hostname = hostname - self.port = port - - def emit(self, record: logging.LogRecord) -> None: - try: - payload = json.dumps(record_to_dict(record)).encode() - self.socket.send(payload) - except Exception: - self.handleError(record) - except Exception: - self.handleError(record) - - def start(self) -> None: - self.socket.connect(f"tcp://{self.hostname}:{self.port}") - - def stop(self) -> None: - self.socket.close() - - def __init__( - self, - hostname: str, - port: int, - ): - - self.log_queue = mp.Queue() - - self.log_handler = self.LogPusherHandler(hostname, port) - self.level_filter = LevelFilter(logging.DEBUG) - self.log_listener: QueueListener = setup_log_listener( - self.log_queue, self.level_filter, self.log_handler - ) - - self.logger = setup_logger("LogPusher", log_queue=self.log_queue) - - def start(self) -> None: - self.log_handler.start() - self.log_listener.start() - - def stop(self): # todo _started - self.logger.info("stopping") - time.sleep(0.2) - self.log_listener.stop() - self.log_handler.stop() diff --git a/src/emotional_hub/input_analysis.py b/src/emotional_hub/input_analysis.py deleted file mode 100644 index 7391578..0000000 --- a/src/emotional_hub/input_analysis.py +++ /dev/null @@ -1,24 +0,0 @@ -import numpy as np -import torch -from transformers import AutoModelForAudioClassification, Wav2Vec2FeatureExtractor - -MODEL_NAME = "superb/hubert-large-superb-er" -model = AutoModelForAudioClassification.from_pretrained(MODEL_NAME) -feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained(MODEL_NAME) - - -def predict_emotion(audio_np: np.ndarray, sr=16000): - if audio_np.dtype != np.float32: - audio_np = audio_np.astype(np.float32) - - inputs = feature_extractor( - audio_np, sampling_rate=sr, return_tensors="pt", padding=True - ) - - with torch.no_grad(): - logits = model(**inputs).logits - - predicted_id = torch.argmax(logits, dim=-1).item() - predicted_label = model.config.id2label[predicted_id] - - return predicted_label diff --git a/src/launch_agent.py b/src/launch_agent.py deleted file mode 100644 index dd91427..0000000 --- a/src/launch_agent.py +++ /dev/null @@ -1,44 +0,0 @@ -import argparse -import logging -import time - -import yaml - -from src.core.agent import Agent, AgentConfig -from src.modules.factory import build_module_factory - - -def load_config(path: str) -> AgentConfig: - with open(path) as f: - raw = yaml.safe_load(f) - - return AgentConfig.from_dict(raw) - - -def main() -> None: - parser = argparse.ArgumentParser(description="HuRI core") - parser.add_argument( - "--config", - required=True, - help="Path to HuRI config file (YAML)", - ) - - args = parser.parse_args() - - config = load_config(args.config) - - build_module_factory() - - agent = Agent(config) - time.sleep(0.1) - try: - agent.run() - except KeyboardInterrupt: - agent.stop() - except Exception as e: - logging.getLogger(__name__).error(e) - agent.stop() - - -if __name__ == "__main__": - main() diff --git a/src/launch_huri.py b/src/launch_huri.py deleted file mode 100644 index b1f9485..0000000 --- a/src/launch_huri.py +++ /dev/null @@ -1,44 +0,0 @@ -import argparse -import logging -import time - -import yaml - -from src.core.huri import HuRI, HuriConfig -from src.modules.factory import build_module_factory - - -def load_config(path: str) -> HuriConfig: - with open(path) as f: - raw = yaml.safe_load(f) - - return HuriConfig.from_dict(raw) - - -def main() -> None: - parser = argparse.ArgumentParser(description="HuRI core") - parser.add_argument( - "--config", - required=True, - help="Path to HuRI config file (YAML)", - ) - - args = parser.parse_args() - - config = load_config(args.config) - - build_module_factory() - - huri = HuRI(config) - time.sleep(0.1) - try: - huri.run() - except KeyboardInterrupt: - huri.stop() - except Exception as e: - logging.getLogger(__name__).error(e) - huri.stop() - - -if __name__ == "__main__": - main() diff --git a/src/modules/factory.py b/src/modules/factory.py deleted file mode 100644 index 74bba03..0000000 --- a/src/modules/factory.py +++ /dev/null @@ -1,33 +0,0 @@ -from typing import Any, Mapping - -from src.core.module import Module - -from .rag.mode_controller import ModeController -from .rag.rag import Rag -from .speech_to_text.record_speech import RecordSpeech -from .speech_to_text.speech_to_text import SpeechToText -from .textIO.input import TextInput -from .textIO.output import TextOutput - - -class ModuleFactory: - _registry = {} - - @classmethod - def register(cls, name: str, module_cls): - cls._registry[name] = module_cls - - @classmethod - def create(cls, name: str, args: Mapping[str, Any] | None = None) -> Module: - if name not in cls._registry: - raise ValueError(f"Unknown module '{name}'") - return cls._registry[name](**args) - - -def build_module_factory() -> None: - ModuleFactory.register("mic", RecordSpeech) - ModuleFactory.register("stt", SpeechToText) - ModuleFactory.register("inp", TextInput) - ModuleFactory.register("out", TextOutput) - ModuleFactory.register("rag", Rag) - ModuleFactory.register("mod", ModeController) diff --git a/src/modules/rag/__init__.py b/src/modules/rag/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/modules/rag/mode_controller.py b/src/modules/rag/mode_controller.py deleted file mode 100644 index e4b6c09..0000000 --- a/src/modules/rag/mode_controller.py +++ /dev/null @@ -1,37 +0,0 @@ -from enum import Enum - -from src.core.module import Module - - -class Modes(Enum): - LLM = 0 - CONTEXT = 1 - RAG = 2 - - -class ModeController(Module): - def __init__(self, default_mode: Modes = Modes.LLM): - super().__init__() - self.mode = default_mode - - def switchMode(self, mode: str) -> None: - self.mode = mode - - def processTextInput(self, text: str): - if "switch llm" in text.lower(): - self.switchMode(Modes.LLM) - elif "switch context" in text.lower(): - self.switchMode(Modes.CONTEXT) - elif "switch rag" in text.lower(): - self.switchMode(Modes.RAG) - elif "bye bye" in text.lower(): - self.publish("exit") # TODO handle (manager being a module) usefull ? - elif text.strip() == "": - return - else: - topic = f"{str(self.mode.name).lower()}.in" - self.publish(topic, text=text) - - def set_subscriptions(self): - self.subscribe("text.in", self.processTextInput) - self.subscribe("mode.switch", self.switchMode) diff --git a/src/modules/rag/rag.py b/src/modules/rag/rag.py deleted file mode 100644 index b083abd..0000000 --- a/src/modules/rag/rag.py +++ /dev/null @@ -1,97 +0,0 @@ -import json -import pathlib - -from langchain.chains import create_retrieval_chain -from langchain.chains.combine_documents import create_stuff_documents_chain -from langchain.text_splitter import RecursiveCharacterTextSplitter -from langchain_chroma import Chroma -from langchain_community.document_loaders import TextLoader -from langchain_core.documents import Document -from langchain_core.prompts import ChatPromptTemplate -from langchain_ollama.embeddings import OllamaEmbeddings -from langchain_ollama.llms import OllamaLLM -from langgraph.checkpoint.memory import MemorySaver - -from src.core.module import Module - - -class Rag(Module): - def __init__( - self, - model: str = "deepseek-v2:16b", - collectionName: str = "vectorStore", - vectorstorePath: str = "src/rag/vectorStore", - ): - super().__init__() - self.memory = MemorySaver() - self.embeddings = OllamaEmbeddings(model=model) - self.llm = OllamaLLM(model=model) - self.vectorstore = Chroma( - collection_name=collectionName, - embedding_function=self.embeddings, - persist_directory=vectorstorePath, - ) - self.textSplitter = RecursiveCharacterTextSplitter( - chunk_size=1000, chunk_overlap=200 - ) - self.retriever = self.vectorstore.as_retriever() - self.systemPrompt = "Conversation history:\n{history}\n\nContext:\n{context}" - self.prompt = ChatPromptTemplate.from_messages( - [ - ("system", self.systemPrompt), - ("human", "{input}"), - ] - ) - self.questionChain = create_stuff_documents_chain(self.llm, self.prompt) - self.qaChain = create_retrieval_chain(self.retriever, self.questionChain) - self.documents = [] - self.docs = [] - self.conversation = [] - self.conversation_log = {"conversation": []} - - def ragFill(self, text: str) -> None: - self.documents += self.textSplitter.split_documents( - [Document(page_content=text)] - ) - self.vectorstore.add_documents(self.documents) - - def ragLoad(self, folderPath: str, fileType: str) -> None: - if fileType == "txt": - for file in pathlib.Path(folderPath).rglob("*.txt"): - fileLoader = TextLoader(file_path=folderPath + "/" + file.name) - self.documents += self.textSplitter.split_documents(fileLoader.load()) - self.vectorstore.add_documents(self.documents) - - def ragQuestion(self, text: str) -> None: - question = text - self.logger.debug("question:", question) - history = "\n".join( - [ - f"Human: {qa['question']}\nAI: {qa['answer']}" - for qa in self.conversation_log["conversation"] - ] - ) - helpingContext = "Answer with just your message like in a conversation. " - question = helpingContext + question - self.logger.debug("full question:", question) - response = self.qaChain.invoke({"history": history, "input": question}) - answer = response["answer"] - self.logger.debug("answer:", answer) - self.conversation_log["conversation"].append( - {"question": question.split(helpingContext)[1:], "answer": answer} - ) - self.publish("llm.response", text=answer) - - def saveConversation(self, filename: str = "conversation_log.json"): - with open(filename, "w") as f: - json.dump(self.conversation_log, f, indent=4) - - def set_subscriptions(self) -> None: - self.subscribe("rag.load", self.ragLoad) - self.subscribe("llm.in", self.ragQuestion) - self.subscribe("rag.in", self.ragFill) - self.subscribe("rag.save", self.saveConversation) - - def run_module(self, stop_event=None) -> None: - self.ragLoad("tests/rag/docsRag", "txt") - super().run_module(stop_event) diff --git a/src/modules/textIO/input.py b/src/modules/textIO/input.py deleted file mode 100644 index 58ee567..0000000 --- a/src/modules/textIO/input.py +++ /dev/null @@ -1,17 +0,0 @@ -from src.core.module import Module - - -class TextInput(Module): - def set_subscriptions(self): - self.subscribe("std.in", self.stdin_to_text) - self.subscribe("std.out", lambda: print(">> ", end="", flush=True)) - - def stdin_to_text(self, data): - print(">> ", end="", flush=True) - if data == "": - return - self.publish("text.in", text=data) - - def run_module(self, stop_event=None): - print(">> ", end="", flush=True) - stop_event.wait() diff --git a/src/modules/textIO/output.py b/src/modules/textIO/output.py deleted file mode 100644 index c461e7a..0000000 --- a/src/modules/textIO/output.py +++ /dev/null @@ -1,10 +0,0 @@ -from src.core.module import Module - - -class TextOutput(Module): - def set_subscriptions(self) -> None: - self.subscribe("llm.response", self.print_response) - - def print_response(self, text: str) -> None: - print(f"\r<< {text}") - self.publish("std.out") From 2ac1433a92272959fdb3895823dd478fea0e25c3 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 16 Mar 2026 10:21:12 +0100 Subject: [PATCH 11/65] refacto(module): is now a basic processor with one input and output type --- src/core/module.py | 154 ++------------------------------------------- 1 file changed, 6 insertions(+), 148 deletions(-) diff --git a/src/core/module.py b/src/core/module.py index db8e08e..413cd7f 100644 --- a/src/core/module.py +++ b/src/core/module.py @@ -1,151 +1,9 @@ -import threading -from abc import ABC, abstractmethod -from multiprocessing.synchronize import Event -from typing import Any, Callable, Dict, Mapping, final +from typing import Any, Optional -import zmq -from src.core.events import ModuleEvent -from src.tools.logger import logging +class Module: + input_type: str + output_type: str - -class Module(ABC): - def __init__(self): - """Child Modules must call super.__init__() in their __init__() function.""" - self.ctx = None - self.pub_socket = None - self.connect_hostname = None - self.xpub_port = None - self.xsub_port = None - self.subs: Dict[str, zmq.Socket[bytes]] = {} - self.callbacks = {} - self._poller_running = False - self.poller = None - self.logger = logging.getLogger(__name__) - - @final - def _initialize(self) -> None: - """ - Called inside start_module() or manually before usage. - This function exist because ctx cannot be set in __init__, because of multi-processing. maybe deprecated - """ - self.ctx = zmq.Context() - self.pub_socket = self.ctx.socket(zmq.PUB) - self.pub_socket.connect(f"tcp://{self.connect_hostname}:{self.xpub_port}") - self.poller = threading.Thread(target=self._poll_loop, daemon=True) - self.set_subscriptions() - - @abstractmethod - def set_subscriptions(self) -> None: - """Child module must define this funcction with subscriptions""" - ... - - @final - def subscribe(self, topic: str, callback: Callable) -> None: - sub_socket = self.ctx.socket(zmq.SUB) - sub_socket.connect(f"tcp://{self.connect_hostname}:{self.xsub_port}") - sub_socket.setsockopt_string(zmq.SUBSCRIBE, topic) - self.subs[topic] = sub_socket - self.callbacks[topic] = callback - self.logger.info(f"Subscribe: {topic}") - - @final - def publish( - self, - topic: str, - **kwargs: Mapping[str, Any], - ) -> None: - """ - Will publish a ModuleEvent to other modules. - - :param topic: the topic of the event - :type topic: str - :param kwargs: kwargs must be named as the receiving module's callbacks - :type kwargs: Mapping[str, Any] - """ - event = ModuleEvent(topic=topic, payload=kwargs) - self.logger.info(f"Publish: {topic} {kwargs.keys()}") - self.pub_socket.send_multipart(event.serialize()) - - @final - def _start_polling(self) -> None: - self._poller_running = True - self.poller.start() - - @final - def _poll_loop(self) -> None: - poller = zmq.Poller() - for sub in self.subs.values(): - poller.register(sub, zmq.POLLIN) - - while self._poller_running: - events = dict(poller.poll(100)) - for _, sub in self.subs.items(): - if sub in events: - data = sub.recv_multipart() - event = ModuleEvent.deserialize(data) - - self.logger.info(f"Receive: {event.topic} {event.payload.keys()}") - self.callbacks[event.topic](**event.payload) - - @final - def start_module( - self, - connect_hostname: str, - xpub_port: int, - xsub_port: int, - stop_event: Event = None, - ) -> None: - self.connect_hostname = connect_hostname - self.xpub_port = xpub_port - self.xsub_port = xsub_port - self._initialize() - if self.subs != {}: - self._start_polling() - try: - self.run_module(stop_event) - except KeyboardInterrupt: - self.logger.info("Ctrl+C pressed, exiting cleanly") - except Exception as e: - self.logger.error(e) - finally: - self.stop_module() - - @final - def stop_module(self) -> None: - """Stop the module gracefully.""" - - if self._poller_running: - self._poller_running = False - self.poller.join() - - for topic, sub in self.subs.items(): - try: - sub.close(0) - except Exception as e: - self.logger.error(f"Error closing SUB socket for '{topic}': {e}") - - self.subs.clear() - self.callbacks.clear() - - try: - self.pub_socket.close(0) - except Exception as e: - self.logger.error(f"Error closing SUB socket for '{topic}': {e}") - - try: - self.ctx.term() - except Exception as e: - self.logger.error(f"Error terminating ZMQ context: {e}") - - self.logger.info("Module stopped gracefully.") - - def run_module(self, stop_event: Event = None) -> None: - """Child modules override this instead of run(). Default: idle wait.""" - if stop_event: - stop_event.wait() - - @final - def set_custom_logger(self, logger) -> None: - """The default logger in set in __init__.""" - self.logger = logger + async def process(self, _) -> Optional[Any]: + raise NotImplementedError From d6eae24016828a7017587c1ffe85afc72ba6e95b Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 16 Mar 2026 10:22:12 +0100 Subject: [PATCH 12/65] feat(session+EventGraph): has an EventGraph that connects modules and publish events --- src/core/events.py | 76 +++++++++++++-------------------------------- src/core/session.py | 12 +++++++ 2 files changed, 34 insertions(+), 54 deletions(-) create mode 100644 src/core/session.py diff --git a/src/core/events.py b/src/core/events.py index d3ca555..0d3653d 100644 --- a/src/core/events.py +++ b/src/core/events.py @@ -1,65 +1,33 @@ -import json -from dataclasses import dataclass -from enum import Enum -from typing import Any, Dict, List, Mapping, Sequence +import asyncio +from collections import defaultdict +from .module import Module -@dataclass -class ModuleEvent: - """ - Inter-Module communication event - Module subscribe to a topic and link a callback. - The payload must correspond to a mapping of params of the callback. - """ - topic: str - payload: Mapping[str, Any] +class EventGraph: - @classmethod - def from_dict(cls, raw: Dict): - return cls(topic=raw["topic"], payload=raw["payload"]) + def __init__(self): - def serialize(self) -> Sequence: - return [self.topic.encode(), json.dumps(self.payload).encode()] + self.subscribers = defaultdict(list) - @classmethod - def deserialize(cls, raw: List[bytes]): - topic, payload = raw - return cls(topic=topic.decode(), payload=json.loads(payload.decode())) + def register(self, module: Module): + self.subscribers[module.input_type].append(module) + async def publish(self, event_topic, data): + for module in self.subscribers[event_topic]: + asyncio.create_task(self._run(module, data)) -class Control(Enum): - # Agent -> HuRI - REGISTER = "REGISTER" # send auth + agent config - HEARTBEAT = "HEARTBEAT" # send agent heartbeat + modified config - EXITED = "EXITED" # send exited info - # HuRI -> Agents - AUTH_OK = "AUTH_OK" # send huri config (after) - START = "START" # start all modules - STOP = "STOP" # stop all modules - START_MODULE = "START_MODULE" # start specific modules - STOP_MODULE = "STOP_MODULE" # stop specific modules - EXIT = "EXIT" # exit agent + async def _run(self, module: Module, data): + result = module.process(data) -@dataclass -class ControlEvent: - ctrl: Control - payload: Mapping[str, Any] + if hasattr(result, "__aiter__"): + async for item in result: + if item is None: + continue + await self.publish(module.output_type, item) - @classmethod - def from_dict(cls, raw: Dict): - return cls(ctrl=Control(raw["ctrl"]), payload=raw["payload"]) - - def serialize(self) -> Sequence: - print(self.ctrl.value) - - return [ - self.ctrl.value.encode(), - json.dumps(self.payload).encode(), - ] - - @classmethod - def deserialize(cls, raw: List[bytes]): - ctrl, payload = raw - return cls(ctrl=Control(ctrl.decode()), payload=json.loads(payload.decode())) + else: + value = await result + if value is not None: + await self.publish(module.output_type, value) diff --git a/src/core/session.py b/src/core/session.py new file mode 100644 index 0000000..85d6712 --- /dev/null +++ b/src/core/session.py @@ -0,0 +1,12 @@ +from .events import EventGraph + + +class Session: + def __init__(self, modules): + self.event_graph = EventGraph() + + for module in modules: + self.event_graph.register(module) + + async def publish(self, topic, data): + await self.event_graph.publish(topic, data) From efe6cf839936b75f4116904066b66534a8ee2c04 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 16 Mar 2026 10:27:20 +0100 Subject: [PATCH 13/65] refacto(huri): huri is now a fastapi ray server, launching ray deployment (connect via websocket) --- src/core/huri.py | 167 ++++++++++++----------------------------------- 1 file changed, 40 insertions(+), 127 deletions(-) diff --git a/src/core/huri.py b/src/core/huri.py index 664aab4..6883687 100644 --- a/src/core/huri.py +++ b/src/core/huri.py @@ -1,136 +1,49 @@ -import sys -import threading -from dataclasses import dataclass - -from src.tools.logger import setup_logger +import uuid from typing import Dict -from .zmq.control_channel import Router -from src.core.events import Control, ControlEvent -from .zmq.event_proxy import EventProxy -from .zmq.log_channel import LogPuller -from src.core.agent import AgentConfig - - -@dataclass -class RouterConfig: - port: int - - -@dataclass -class EventProxyConfig: - xsub: int - xpub: int - -@dataclass -class LogPullerConfig: - port: int +from fastapi import FastAPI, WebSocket +from ray import serve +from ray.serve import handle +from src.modules.speech_to_text.record_speech import MIC +from src.modules.speech_to_text.speech_to_text import STT +from src.modules.utils.sender import Sender -@dataclass -class HuriConfig: - hostname: str - router: RouterConfig - event_proxy: EventProxyConfig - log_puller: LogPullerConfig +from .session import Session - @classmethod - def from_dict(cls, raw: dict): - return cls( - hostname=raw["hostname"], - router=RouterConfig(**raw["router"]), - event_proxy=EventProxyConfig(**raw["event-proxy"]), - log_puller=LogPullerConfig(**raw["log-puller"]), - ) - - -@dataclass -class AgentStatus: - update_time: int - config: AgentConfig - status: Dict[str, int] - - @classmethod - def from_dict(cls, raw: dict): - return cls( - update_time=raw["update_time"], - config=AgentConfig.from_dict(raw["config"]), - status=raw["status"], - ) +app = FastAPI() +@serve.deployment +@serve.ingress(app) class HuRI: - """Wait for Agent to connect, handle module communication and Logging""" - - def __init__(self, config: HuriConfig) -> None: + def __init__(self, config, handles: Dict[str, handle.DeploymentHandle]) -> None: self.config = config - - self.router = Router(config.hostname, config.router.port, self._control_handler) - self.event_proxy = EventProxy( - config.hostname, "", config.event_proxy.xpub, config.event_proxy.xsub - ) - self.log_channel = LogPuller(config.hostname, config.log_puller.port) - - self.agents: Dict[bytes, AgentStatus] = {} - - self.stop_event = threading.Event() - - self.logger = setup_logger("HuRI") - - def _control_handler( - self, identity: bytes, event: ControlEvent - ) -> bool: # todo data race ? - match event.ctrl: - case Control.REGISTER: - if event.payload["auth"] != "oui": # todo wip - return False - self.router.dealers[identity] = True - self.agents[identity] = AgentStatus.from_dict(**event.payload["agent"]) - - self.router.send_control( - identity, Control.AUTH_OK, self.config - ) # todo send all config ? - self.router.send_control(identity, Control.START) - return True - case Control.HEARTBEAT: - # previous_config = self.router.dealers[identity] - # self.agents[identity] = todo - # todo AgentStatus concat - return True - case Control.EXITED: - del self.router.dealers[identity] - del self.agents[identity] - case _: - return False # todo log - - def run(self) -> None: - """ - Start LogPuller. - Start Router. - Start EventProxy. - Then loop over RobotShell.cmdloop() to use HuRI commands. - Then, when exit is requested, call stop() - """ - - "Used to handle log filtering and displaying" - self.log_channel.start() - "Used to handle Agent registration and control" - self.router.start() - "Used to handle inter-module communication, though events" - self.event_proxy.start(False, False) - - if not sys.stdin.isatty(): - self.stop_event.wait() - return - - from src.core.shell import RobotShell - - RobotShell(self).cmdloop() - - self.stop() - - def stop(self) -> None: - self.router.stop() - self.event_proxy.stop() - self.log_channel.stop() - print("Fully stopped") + self.handles = handles + + self.clients: Dict[str, Session] = {} + + @app.websocket("/session") + async def run_session(self, ws: WebSocket): + await ws.accept() + + modules = [ + STT(self.handles["stt"]), + MIC(5), + Sender(ws, "text"), + ] + session_id = str(uuid.uuid4()) + + self.clients[session_id] = Session(modules) + + async def receive_loop(session: Session, ws: WebSocket): + while True: + msg = await ws.receive() + if "bytes" in msg: + chunk = msg["bytes"] + await session.publish("chunk", chunk) + # else: + # data = msg + # await session.publish(data["type"], data["data"]) + + await receive_loop(self.clients[session_id], ws) From 90b6d01640b29aad1ff712c7a0009b0a5ebeef6d Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 16 Mar 2026 10:28:10 +0100 Subject: [PATCH 14/65] evol(launch_huri): launch deployement and bind them to main ray server (huri) --- src/launch_huri.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/launch_huri.py diff --git a/src/launch_huri.py b/src/launch_huri.py new file mode 100644 index 0000000..6e12bcb --- /dev/null +++ b/src/launch_huri.py @@ -0,0 +1,26 @@ +import time + +import ray + +from src.core.huri import Dict, HuRI, handle, serve +from src.modules.speech_to_text.speech_to_text import STTHandle + + +def main() -> None: + ray.init() + + services: Dict[str, handle.DeploymentHandle] = { + "stt": STTHandle.bind(), + } + app = HuRI.bind("", services) + time.sleep(0.1) + try: + serve.run(app, name="HuRI", blocking=True) + except KeyboardInterrupt: + return + except Exception as e: + ray.logger.error(e) + + +if __name__ == "__main__": + main() From 3d6936cc45d9e82428c1b39888741e78f6c5dccf Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 16 Mar 2026 10:29:43 +0100 Subject: [PATCH 15/65] wip(client): connect to huri via websocket, stream audio and receive huri's ou tput --- src/client.py | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/client.py diff --git a/src/client.py b/src/client.py new file mode 100644 index 0000000..d3d1dd1 --- /dev/null +++ b/src/client.py @@ -0,0 +1,123 @@ +# import sounddevice as sd +# import websocket +# import numpy as np +# import time + +# # import microphone +# # import stt +# import ray +# from abc import ABC, abstractmethod +# from huri import HuRI +# from ray.actor import ActorHandle +# from fastapi import WebSocket + + +# class Client(ABC): +# def __init__(self): + +# # huri = ray.get_actor(name="HuRI", namespace="h") + +# # c = ray.get(huri.new_client.remote()) +# # self.session = ray.get_actor(name=c, namespace="h") + +# @abstractmethod +# def input(self): +# pass + +# # @abstractmethod +# # def output(self): +# # pass + +# def ingestion(self, type): +# self.session.push_in.remote(self.input(), type) + +# def outgestion(self): +# return ray.get(self.session.get_response.remote()) + + +# class AudioClient(Client): +# def __init__(self): +# super().__init__() +# self.CHUNK_DURATION: float = 0.3 +# self.SAMPLE_RATE: int = 16000 + +# def input(self): +# chunk: np.ndarray = sd.rec( +# int(self.CHUNK_DURATION * self.SAMPLE_RATE), +# samplerate=self.SAMPLE_RATE, +# channels=1, +# dtype="int16", +# ).ravel() +# sd.wait() +# return chunk + + +# class TerminalClient(Client): +# def __init__(self): +# super().__init__() +# self.CHUNK_DURATION: float = 0.3 +# self.SAMPLE_RATE: int = 16000 + +# def input(self): +# text = input() +# return text + + +# def main(): +# ray.init() + +# client = TerminalClient() +# while True: +# client.ingestion() +# print(client.outgestion()) + + +import asyncio +import time +import wave + +import fastapi +import numpy as np +import sounddevice as sd + +import websockets + +SERVER_URL = "ws://localhost:8000/session" +CHUNK_DURATION = 1 +SAMPLE_RATE = 16000 + + +async def stream_audio(): + + async with websockets.connect(SERVER_URL) as ws: + print("Connected to server") + + async def receive(ws: websockets.ClientConnection): + while True: + text = await ws.recv() + print("received:", text) + + async def send(ws: websockets.ClientConnection): + loop = asyncio.get_running_loop() + + queue = asyncio.Queue() + + def callback(indata: np.ndarray, frames, time, status): + loop.call_soon_threadsafe(queue.put_nowait, indata.copy()) + + with sd.InputStream( + samplerate=SAMPLE_RATE, + channels=1, + dtype="int16", + callback=callback, + blocksize=int(CHUNK_DURATION * SAMPLE_RATE), + ): + while True: + chunk = await queue.get() + await ws.send(chunk.tobytes()) + + await asyncio.gather(receive(ws), send(ws)) + + +if __name__ == "__main__": + asyncio.run(stream_audio()) From 047a655205ad99a18683afaccd71a04046d43134 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 16 Mar 2026 10:30:17 +0100 Subject: [PATCH 16/65] evol(MIC+STT): changes for new archi --- src/modules/speech_to_text/record_speech.py | 102 +++---------------- src/modules/speech_to_text/speech_to_text.py | 70 +++++++------ 2 files changed, 50 insertions(+), 122 deletions(-) diff --git a/src/modules/speech_to_text/record_speech.py b/src/modules/speech_to_text/record_speech.py index 3d87edc..655559d 100644 --- a/src/modules/speech_to_text/record_speech.py +++ b/src/modules/speech_to_text/record_speech.py @@ -6,102 +6,26 @@ import numpy as np import sounddevice as sd -from src.core.module import Event, Module +from src.core.module import Module +# from src.core.reactive_layer.IOprocessor import IOprocessor +# from src.core.reactive_layer.IOgestion import IOgestion + + +class MIC(Module): + input_type = "chunk" + output_type = "voice" -class RecordSpeech(Module): def __init__( self, threshold: int = 0, - silence_duration: float = 1.0, - chunk_duration: float = 0.5, - sample_rate: int = 16000, ): super().__init__() self.THRESHOLD: int = threshold - self.SILENCE_DURATION: float = silence_duration - self.CHUNK_DURATION: float = chunk_duration - self.SAMPLE_RATE: int = sample_rate - self.running: bool = False - self.audio_queue: queue.Queue = queue.Queue() - self.transcriptions: queue.Queue = queue.Queue() - self.pause_record = threading.Semaphore(1) - self.audio_to_process = threading.Semaphore(0) - self.prompt_available = threading.Semaphore(0) - self.noise_profile: np.ndarray - - def reduce_noise(self, chunk: np.ndarray) -> np.ndarray: - if np.abs(chunk).mean() <= self.THRESHOLD: - return chunk - - return np.clip(chunk - self.noise_profile, -32768, 32767).astype(np.int16) - - def record_chunk(self) -> np.ndarray: - self.pause_record.acquire() - chunk: np.ndarray = sd.rec( - int(self.CHUNK_DURATION * self.SAMPLE_RATE), - samplerate=self.SAMPLE_RATE, - channels=1, - dtype="int16", - ).ravel() - sd.wait() - self.pause_record.release() - return self.reduce_noise(chunk) - - def calculate_noise_level(self) -> None: - self.logger.info("Listening for 10 seconds to calculate noise level...") - noise_chunk: np.ndarray = sd.rec( - int(10 * self.SAMPLE_RATE), - samplerate=self.SAMPLE_RATE, - channels=1, - dtype="int16", - ).ravel() - sd.wait() - self.noise_profile = noise_chunk.mean(axis=0) - self.THRESHOLD = np.abs(self.reduce_noise(noise_chunk)).mean() - self.logger.info(f"Threshold: {self.THRESHOLD}") - - def record_audio(self, starting_chunk, stop_event: Event = None) -> None: - buffer: List[np.ndarray] = [starting_chunk] - silence_start: Optional[float] = None - - while stop_event is None or not stop_event.is_set(): - chunk = self.record_chunk() - buffer.append(chunk) - - if np.abs(chunk).mean() <= self.THRESHOLD: - if silence_start is None: - silence_start = time.time() - elif time.time() - silence_start >= self.SILENCE_DURATION: - if buffer == []: - break - speech = np.concatenate(buffer, axis=0) - self.publish("speech.in", buffer=speech.tobytes()) - break - else: - silence_start = None - - def set_subscriptions(self) -> None: - self.subscribe("speech.in.pause", lambda: self.pause()) - self.subscribe("speech.in.resume", lambda: self.pause(False)) - - def run_module(self, stop_event: Event = None) -> None: - if not self.THRESHOLD: - self.calculate_noise_level() - else: - self.noise_profile = np.zeros( - int(self.CHUNK_DURATION * self.SAMPLE_RATE), dtype=np.int16 - ) - - while stop_event is None or not stop_event.is_set(): - chunk: np.ndarray = self.record_chunk() - - if np.abs(chunk).mean() > self.THRESHOLD: - self.record_audio(chunk, stop_event) - def pause(self, true: bool = True) -> None: - if true: - self.pause_record.acquire() - else: - self.pause_record.release() + async def process(self, data: bytes) -> np.ndarray: + audio_array = np.frombuffer(data, dtype=np.int16) + if np.abs(audio_array).mean() > self.THRESHOLD: + audio_array = audio_array.astype(np.float32) / 32768.0 + return audio_array diff --git a/src/modules/speech_to_text/speech_to_text.py b/src/modules/speech_to_text/speech_to_text.py index 6c087b0..bcae6c5 100644 --- a/src/modules/speech_to_text/speech_to_text.py +++ b/src/modules/speech_to_text/speech_to_text.py @@ -1,50 +1,54 @@ +import asyncio import queue import threading +from typing import Optional import numpy as np import whisper +from ray import serve +from ray.serve import handle from src.core.module import Module -class SpeechToText(Module): +@serve.deployment(num_replicas=5) +class STTHandle: def __init__( self, - model_name: str = "base.en", - device: str = "cpu", - sample_rate: int = 16000, + model_name: str = "base", ): super().__init__() - print(model_name) - if device == "cpu": - import warnings - - warnings.filterwarnings( - "ignore", message="FP16 is not supported on CPU; using FP32 instead" - ) - self.model: whisper.Whisper = whisper.load_model(model_name, device=device) - self.SAMPLE_RATE: int = sample_rate - self.running: bool = False - self.audio_queue: queue.Queue = queue.Queue() - self.transcriptions: queue.Queue = queue.Queue() - self.pause_record = threading.Semaphore(1) - self.audio_to_process = threading.Semaphore(0) - self.prompt_available = threading.Semaphore(0) - self.noise_profile: np.ndarray - - def process_audio(self, buffer: bytes) -> None: - if not buffer: - return - - audio_array = np.frombuffer(buffer, dtype=np.int16) - audio_array = audio_array.astype(np.float32) / 32768.0 - - result: dict = self.model.transcribe(audio_array, language="en") + + self.model: whisper.Whisper = whisper.load_model(model_name) + + async def process(self, audio_array: np.ndarray) -> Optional[str]: + result: dict = self.model.transcribe( + audio_array.copy(), condition_on_previous_text=False, fp16=False + ) result["text"] = result["text"].strip() if not result["text"] or result["text"] == "": - return + return None + + return result["text"] + + +class STT(Module): + input_type = "voice" + output_type = "text" + + def __init__(self, stt_handle: handle.DeploymentHandle[STTHandle]): + self.stt = stt_handle - self.publish("text.in", text=result["text"]) + self.chunks = [] + self.running = False - def set_subscriptions(self) -> None: - self.subscribe("speech.in", self.process_audio) + async def process(self, audio: np.ndarray) -> Optional[str]: + self.chunks.append(audio) + if self.running is True: + return None + self.running = True + text = await self.stt.process.remote(np.concatenate(self.chunks, axis=0)) + self.chunks.clear() + self.running = False + print(text) + return text From 78513e9d7c0a2d51436fc7c57a3e483e7602e85e Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 16 Mar 2026 10:30:45 +0100 Subject: [PATCH 17/65] add(Sender): module that sends huri output to client --- src/modules/utils/__init__.py | 0 src/modules/utils/sender.py | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 src/modules/utils/__init__.py create mode 100644 src/modules/utils/sender.py diff --git a/src/modules/utils/__init__.py b/src/modules/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/utils/sender.py b/src/modules/utils/sender.py new file mode 100644 index 0000000..4d142f6 --- /dev/null +++ b/src/modules/utils/sender.py @@ -0,0 +1,18 @@ +from src.core.module import Module +from src.core.huri import WebSocket +from typing import Any + + +class Sender(Module): + """Module to send output data to the client""" + + input_type = ... + output_type = None + + def __init__(self, ws: WebSocket, type: str): + super().__init__() + self.ws: WebSocket = ws + self.input_type = type + + async def process(self, data: Any): + await self.ws.send_text(data) From 48e750bf6ddfd7ab58b0b46e45d347a85c88efa0 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 16 Mar 2026 10:56:47 +0100 Subject: [PATCH 18/65] remove(logger): deprecated logger --- src/tools/logger.py | 104 -------------------------------------------- 1 file changed, 104 deletions(-) delete mode 100644 src/tools/logger.py diff --git a/src/tools/logger.py b/src/tools/logger.py deleted file mode 100644 index 1b7d5c5..0000000 --- a/src/tools/logger.py +++ /dev/null @@ -1,104 +0,0 @@ -import logging -import multiprocessing as mp -from logging.handlers import QueueHandler, QueueListener -from typing import IO, Dict, Optional - - -def setup_handler( - stream: Optional[IO] = None, - filename: Optional[str] = None, - log_queue: Optional[mp.Queue] = None, - formatter: logging.Formatter = logging.Formatter( - "[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s", datefmt="%H:%M:%S" - ), -) -> logging.Handler: - if stream is not None: - handler = logging.StreamHandler(stream) - elif filename is not None: - handler = logging.FileHandler(filename) - elif log_queue is not None: - return QueueHandler(log_queue) - else: - # Default: stdout - handler = logging.StreamHandler() - - handler.setFormatter(formatter) - - return handler - - -def setup_logger( - name: str, - level: int = logging.DEBUG, - stream: Optional[IO] = None, - filename: Optional[str] = None, - log_queue: Optional[mp.Queue] = None, -) -> logging.Logger: - """ - Creates and returns a logger with optional output: - - log_queue (multiprocessing-safe queue, preferred for child processes) - - stream (e.g., sys.stdout) - - filename (log file) - - defaults to stdout if none is given - """ - logger = logging.getLogger(name) - logger.setLevel(level) - if log_queue: - logger.propagate = False - - logger.handlers.clear() - handler = setup_handler(stream, filename, log_queue) - logger.addHandler(handler) - - return logger - - -class LevelFilter(logging.Filter): - def __init__(self, root_level: int = logging.WARNING): - self.root_level = root_level - self.log_levels: Dict[str, int] = {} - - def filter(self, record: logging.LogRecord) -> bool: - """the root level has priority over custom levels""" - level = self.log_levels.get(record.name, self.root_level) - - return self.root_level <= record.levelno and level <= record.levelno - - def set_root_level(self, level: int) -> None: - self.root_level = level - - def add_level(self, name: str) -> None: - self.log_levels[name] = self.root_level - - def set_level(self, name: str, level: int) -> None: - if name not in self.log_levels: - raise ValueError(f"{name} has no linked log level") - self.log_levels[name] = level - - def set_levels(self, level: int) -> None: - self.set_root_level(level) - for name in self.log_levels: - self.set_level(name, level) - - def del_level(self, name: str) -> None: - del self.log_levels[name] - - -def setup_log_listener( - log_queue: mp.Queue, - filter: logging.Filter, - custom_handler: Optional[logging.Handler] = None, -) -> QueueListener: - """ - Starts a central logging listener that reads LogRecords from a queue - and emits them using normal loggers/handlers. - """ - formatter = logging.Formatter( - "[%(asctime)s] [%(processName)s] [%(name)s] [%(levelname)s] %(message)s", - datefmt="%H:%M:%S", - ) - handler = custom_handler or setup_handler(formatter=formatter) - handler.addFilter(filter) - - listener = QueueListener(log_queue, handler) - return listener From 4bc4b6bb357b8fcec6de06e2eca3be4953a627b4 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 16 Mar 2026 11:12:34 +0100 Subject: [PATCH 19/65] feat(linter): added linter config file + Makefile (make lint) --- Makefile | 10 ++++++++++ pyproject.toml | 31 +++++++++++++++++++++++++++++++ requirements.txt | 7 +++++++ 3 files changed, 48 insertions(+) create mode 100644 Makefile create mode 100644 pyproject.toml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b1c93cf --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +lint: + black . + isort . + flake8 . + mypy . + +test: + pytest + +check: lint test \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..476f4a8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[tool.black] +line-length = 88 +target-version = ["py310"] + +[tool.isort] +profile = "black" +line_length = 88 +multi_line_output = 3 +include_trailing_comma = true +skip_gitignore = true + +[tool.flake8] +max-line-length = 88 +extend-ignore = [] +exclude = """ + __pycache__ + venv + .venv +""" + +[tool.mypy] +python_version = "3.10" +ignore_missing_imports = true +strict_optional = true +warn_unused_ignores = true +warn_return_any = true +warn_unused_configs = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] diff --git a/requirements.txt b/requirements.txt index 863a44c..dbc30d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,10 @@ +black +isort +mypy +flake8 +flake8-toml-config +pytest + deepfilternet sounddevice soundfile From d5071e1a5f8a277d50609005b1843c8b8342a582 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 16 Mar 2026 11:12:58 +0100 Subject: [PATCH 20/65] fix(linter): make lint --- src/client.py | 80 +------------------- src/core/module.py | 4 +- src/launch_huri.py | 4 +- src/modules/speech_to_text/record_speech.py | 16 ++-- src/modules/speech_to_text/speech_to_text.py | 12 +-- src/modules/utils/sender.py | 7 +- 6 files changed, 18 insertions(+), 105 deletions(-) diff --git a/src/client.py b/src/client.py index d3d1dd1..6c439ee 100644 --- a/src/client.py +++ b/src/client.py @@ -1,85 +1,7 @@ -# import sounddevice as sd -# import websocket -# import numpy as np -# import time - -# # import microphone -# # import stt -# import ray -# from abc import ABC, abstractmethod -# from huri import HuRI -# from ray.actor import ActorHandle -# from fastapi import WebSocket - - -# class Client(ABC): -# def __init__(self): - -# # huri = ray.get_actor(name="HuRI", namespace="h") - -# # c = ray.get(huri.new_client.remote()) -# # self.session = ray.get_actor(name=c, namespace="h") - -# @abstractmethod -# def input(self): -# pass - -# # @abstractmethod -# # def output(self): -# # pass - -# def ingestion(self, type): -# self.session.push_in.remote(self.input(), type) - -# def outgestion(self): -# return ray.get(self.session.get_response.remote()) - - -# class AudioClient(Client): -# def __init__(self): -# super().__init__() -# self.CHUNK_DURATION: float = 0.3 -# self.SAMPLE_RATE: int = 16000 - -# def input(self): -# chunk: np.ndarray = sd.rec( -# int(self.CHUNK_DURATION * self.SAMPLE_RATE), -# samplerate=self.SAMPLE_RATE, -# channels=1, -# dtype="int16", -# ).ravel() -# sd.wait() -# return chunk - - -# class TerminalClient(Client): -# def __init__(self): -# super().__init__() -# self.CHUNK_DURATION: float = 0.3 -# self.SAMPLE_RATE: int = 16000 - -# def input(self): -# text = input() -# return text - - -# def main(): -# ray.init() - -# client = TerminalClient() -# while True: -# client.ingestion() -# print(client.outgestion()) - - import asyncio -import time -import wave -import fastapi import numpy as np import sounddevice as sd - import websockets SERVER_URL = "ws://localhost:8000/session" @@ -100,7 +22,7 @@ async def receive(ws: websockets.ClientConnection): async def send(ws: websockets.ClientConnection): loop = asyncio.get_running_loop() - queue = asyncio.Queue() + queue: asyncio.Queue = asyncio.Queue() def callback(indata: np.ndarray, frames, time, status): loop.call_soon_threadsafe(queue.put_nowait, indata.copy()) diff --git a/src/core/module.py b/src/core/module.py index 413cd7f..0ae9cad 100644 --- a/src/core/module.py +++ b/src/core/module.py @@ -2,8 +2,8 @@ class Module: - input_type: str - output_type: str + input_type: Optional[str] + output_type: Optional[str] async def process(self, _) -> Optional[Any]: raise NotImplementedError diff --git a/src/launch_huri.py b/src/launch_huri.py index 6e12bcb..729ee79 100644 --- a/src/launch_huri.py +++ b/src/launch_huri.py @@ -10,9 +10,9 @@ def main() -> None: ray.init() services: Dict[str, handle.DeploymentHandle] = { - "stt": STTHandle.bind(), + "stt": STTHandle.bind(), # type: ignore[attr-defined] } - app = HuRI.bind("", services) + app = HuRI.bind("", services) # type: ignore[attr-defined] time.sleep(0.1) try: serve.run(app, name="HuRI", blocking=True) diff --git a/src/modules/speech_to_text/record_speech.py b/src/modules/speech_to_text/record_speech.py index 655559d..0f28755 100644 --- a/src/modules/speech_to_text/record_speech.py +++ b/src/modules/speech_to_text/record_speech.py @@ -1,16 +1,9 @@ -import queue -import threading -import time -from typing import List, Optional +from typing import Optional import numpy as np -import sounddevice as sd from src.core.module import Module -# from src.core.reactive_layer.IOprocessor import IOprocessor -# from src.core.reactive_layer.IOgestion import IOgestion - class MIC(Module): input_type = "chunk" @@ -24,8 +17,9 @@ def __init__( self.THRESHOLD: int = threshold - async def process(self, data: bytes) -> np.ndarray: + async def process(self, data: bytes) -> Optional[np.ndarray]: audio_array = np.frombuffer(data, dtype=np.int16) if np.abs(audio_array).mean() > self.THRESHOLD: - audio_array = audio_array.astype(np.float32) / 32768.0 - return audio_array + audio_array_float = audio_array.astype(np.float32) / 32768.0 + return audio_array_float + return None diff --git a/src/modules/speech_to_text/speech_to_text.py b/src/modules/speech_to_text/speech_to_text.py index bcae6c5..7925f3c 100644 --- a/src/modules/speech_to_text/speech_to_text.py +++ b/src/modules/speech_to_text/speech_to_text.py @@ -1,7 +1,4 @@ -import asyncio -import queue -import threading -from typing import Optional +from typing import Any, List, Optional import numpy as np import whisper @@ -21,7 +18,7 @@ def __init__( self.model: whisper.Whisper = whisper.load_model(model_name) - async def process(self, audio_array: np.ndarray) -> Optional[str]: + async def process(self, audio_array: np.ndarray) -> Optional[Any]: result: dict = self.model.transcribe( audio_array.copy(), condition_on_previous_text=False, fp16=False ) @@ -39,10 +36,10 @@ class STT(Module): def __init__(self, stt_handle: handle.DeploymentHandle[STTHandle]): self.stt = stt_handle - self.chunks = [] + self.chunks: List[np.ndarray] = [] self.running = False - async def process(self, audio: np.ndarray) -> Optional[str]: + async def process(self, audio: np.ndarray) -> Optional[Any]: self.chunks.append(audio) if self.running is True: return None @@ -50,5 +47,4 @@ async def process(self, audio: np.ndarray) -> Optional[str]: text = await self.stt.process.remote(np.concatenate(self.chunks, axis=0)) self.chunks.clear() self.running = False - print(text) return text diff --git a/src/modules/utils/sender.py b/src/modules/utils/sender.py index 4d142f6..e4c45a2 100644 --- a/src/modules/utils/sender.py +++ b/src/modules/utils/sender.py @@ -1,12 +1,13 @@ -from src.core.module import Module -from src.core.huri import WebSocket from typing import Any +from src.core.huri import WebSocket +from src.core.module import Module + class Sender(Module): """Module to send output data to the client""" - input_type = ... + input_type = None output_type = None def __init__(self, ws: WebSocket, type: str): From 06334b72f3e593853b2a9ed04eeff3d0838c010c Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 16 Mar 2026 11:15:14 +0100 Subject: [PATCH 21/65] remove(config): deprecated config files + quick_launch.sh --- config/agent_input.yaml | 34 ---------------------------------- config/agent_io.yaml | 36 ------------------------------------ config/huri.yaml | 11 ----------- quick_launch.sh | 40 ---------------------------------------- 4 files changed, 121 deletions(-) delete mode 100644 config/agent_input.yaml delete mode 100644 config/agent_io.yaml delete mode 100644 config/huri.yaml delete mode 100755 quick_launch.sh diff --git a/config/agent_input.yaml b/config/agent_input.yaml deleted file mode 100644 index c122a8b..0000000 --- a/config/agent_input.yaml +++ /dev/null @@ -1,34 +0,0 @@ -id: agent-io -hostname: localhost - -huri: - hostname: localhost - router: - port: 3000 - event-proxy: - xsub: 5555 - xpub: 5556 - log-puller: - port: 8008 - -forwarder-proxy: - down-xsub: 6665 - up-xpub: 6666 - -logging: INFO - -modules: - inp: - name: INP - logging: INFO - out: - name: OUT - logging: INFO - mod: - name: MOD - logging: INFO - rag: - name: RAG - args: - model: deepseek-v2:16b - logging: INFO diff --git a/config/agent_io.yaml b/config/agent_io.yaml deleted file mode 100644 index c9a5646..0000000 --- a/config/agent_io.yaml +++ /dev/null @@ -1,36 +0,0 @@ -id: agent-io -hostname: localhost - -huri: - hostname: localhost - router: - port: 3000 - event-proxy: - xsub: 5555 - xpub: 5556 - log-puller: - port: 8008 - -forwarder-proxy: - down-xsub: 6665 - up-xpub: 6666 - -logging: INFO - -modules: - mic: - name: mic - args: - sample_rate: 18000 - logging: INFO - stt: - name: stt - args: - sample_rate: 18000 - logging: INFO - # tts: - # name: vibe - # args: - # model: vibe-voice - # voice: adrien - # logging: DEBUG diff --git a/config/huri.yaml b/config/huri.yaml deleted file mode 100644 index 13f06b1..0000000 --- a/config/huri.yaml +++ /dev/null @@ -1,11 +0,0 @@ -hostname: localhost - -router: - port: 3000 - -event-proxy: - xsub: 5555 - xpub: 5556 - -log-puller: - port: 8008 diff --git a/quick_launch.sh b/quick_launch.sh deleted file mode 100755 index a76da2a..0000000 --- a/quick_launch.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash - -set -e - -# Check args -if [ "$#" -lt 2 ]; then - echo "Usage: $0 [CLEAN]" - exit 1 -fi - -HURI_CONFIG="$1" -AGENT_CONFIG="$2" - -LOG_DIR="./tmp/log" - -if [[ " $* " == *" CLEAN "* ]]; then - echo "Cleaning previous logs in ${LOG_DIR}" - rm -rf "${LOG_DIR}" -fi - -mkdir -p "$LOG_DIR" - -TIMESTAMP=$(date +"%Y%m%d-%H%M%S") -HURI_LOG="${LOG_DIR}/huri-${TIMESTAMP}.log" - - -# Run huri with output redirected -python -m src.launch_huri --config "$HURI_CONFIG" > "$HURI_LOG" 2>&1 & -HURI_PID=$! -echo "HURI started in background (PID=${HURI_PID}), logging to ${HURI_LOG}" - -# Run agent -python -m src.launch_agent --config "$AGENT_CONFIG" - -# Ensure HURI is killed on script exit (normal or Ctrl+C) -cleanup() { - echo "Stopping HURI (PID=${HURI_PID})" - kill "${HURI_PID}" 2>/dev/null || true -} -trap cleanup EXIT INT TERM \ No newline at end of file From aef8d0a49f9b6c0f50964675902cdf0ee41959a7 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 23 Mar 2026 11:20:07 +0100 Subject: [PATCH 22/65] feat(config): module config and client config --- src/core/dataclasses/config.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/core/dataclasses/config.py diff --git a/src/core/dataclasses/config.py b/src/core/dataclasses/config.py new file mode 100644 index 0000000..6b76a3e --- /dev/null +++ b/src/core/dataclasses/config.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from typing import Any, Dict, List, Mapping + + +@dataclass +class ModuleConfig: + name: str + args: Mapping[str, Any] + + @classmethod + def from_dict(self, raw: dict) -> "ModuleConfig": + return self( + name=raw["name"], + args=raw.get("args", {}), + ) + + +@dataclass +class ClientConfig: + huri_url: str + topic_list: List[str] + modules: Dict[str, ModuleConfig] + + @classmethod + def from_dict(cls, raw: Dict) -> "ClientConfig": + modules = { + module_id: ModuleConfig.from_dict(mod_raw) + for module_id, mod_raw in raw.get("modules", {}).items() + } + return cls( + huri_url=raw["huri_url"], + topic_list=raw["topic_list"], + modules=modules, + ) From 9ff235d9a9a8f6a4e62f9b9faea9d088c7357122 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 23 Mar 2026 11:24:06 +0100 Subject: [PATCH 23/65] evol(module): type ModuleWithHandle --- src/core/module.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/core/module.py b/src/core/module.py index 0ae9cad..12543cf 100644 --- a/src/core/module.py +++ b/src/core/module.py @@ -1,4 +1,6 @@ -from typing import Any, Optional +from typing import Any, Optional, Type + +from ray.serve import handle class Module: @@ -7,3 +9,11 @@ class Module: async def process(self, _) -> Optional[Any]: raise NotImplementedError + + +class ModuleWithHandle(Module): + _handle_cls: Type[Any] + + def __init__(self, handle: handle.DeploymentHandle): + super().__init__() + self.handle = handle From e4fa54fcb2c51719f28f30ea36e9f644af1339e9 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 23 Mar 2026 11:24:43 +0100 Subject: [PATCH 24/65] feat(factory): factory to build modules --- src/modules/factory.py | 64 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/modules/factory.py diff --git a/src/modules/factory.py b/src/modules/factory.py new file mode 100644 index 0000000..39a1a83 --- /dev/null +++ b/src/modules/factory.py @@ -0,0 +1,64 @@ +from typing import Any, Dict, List, Mapping, Type + +from src.core.dataclasses.config import ModuleConfig +from src.core.module import Module, ModuleWithHandle, handle + + +class ModuleFactory: + def __init__(self, handles): + self._registry: Dict[str, Type[Module]] = {} + self._handles = handles + + def register(self, name: str, module_cls: Type[Module]) -> None: + if not issubclass(module_cls, Module): + raise TypeError(f"{module_cls} must inherit from Module") + if issubclass(module_cls, ModuleWithHandle): + if name not in self._handles: + raise RuntimeError( + f"Handles not bound for '{name}'. Check your module config first." + ) + self._registry[name] = module_cls + + def create(self, name: str, args: Mapping[str, Any] | None = None) -> Module: + if name not in self._registry: + raise ValueError(f"Unknown module '{name}'") + module_cls = self._registry[name] + + if args is None: + args = {} + if issubclass(module_cls, ModuleWithHandle): + if name not in self._handles: + raise RuntimeError( + f"Handles not bound for '{name}'. Check your config first." + ) + + return module_cls(handle=self._handles[name], **args) + return module_cls(**args) + + def create_from_config( + self, module_configs: Dict[str, ModuleConfig] + ) -> List[Module]: + modules: List[Module] = [] + for _, module_config in module_configs.items(): + modules.append(self.create(module_config.name, module_config.args)) + + if modules == []: + raise Exception + + return modules + + +def bind_deployment_handles( + modules: Dict[str, Type[Module]], +) -> Dict[str, handle.DeploymentHandle]: + handles: Dict[str, handle.DeploymentHandle] = {} + for name, module_cls in modules.items(): + if not issubclass(module_cls, ModuleWithHandle): + continue + + if not hasattr(module_cls, "_handle_cls"): + raise TypeError(f"{module_cls.__name__} must define _handle_cls") + handle_cls = module_cls._handle_cls + handles[name] = handle_cls.bind() + + return handles From 727b975d255b2c5c963cccacea7d143cbb8da641 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 23 Mar 2026 11:25:37 +0100 Subject: [PATCH 25/65] evol(launch_huri): can now be launch via ray serve config file --- config/huri.yaml | 27 +++++++++++++++++++++++++++ src/app.py | 16 ++++++++++++++++ src/launch_huri.py | 10 ++++------ src/modules/modules.py | 11 +++++++++++ 4 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 config/huri.yaml create mode 100644 src/app.py create mode 100644 src/modules/modules.py diff --git a/config/huri.yaml b/config/huri.yaml new file mode 100644 index 0000000..6bdca3e --- /dev/null +++ b/config/huri.yaml @@ -0,0 +1,27 @@ +proxy_location: EveryNode + +http_options: + host: 0.0.0.0 + port: 8000 + +logging_config: + encoding: TEXT + log_level: INFO + logs_dir: null + enable_access_log: true + additional_log_standard_attrs: [] + +applications: + - name: huri-app + route_prefix: / + import_path: src.app:app + runtime_env: { RAY_COLOR_PREFIX=1 } + deployments: + - name: HuRI + - name: EMBHandle + num_replicas: 1 + - name: STTHandle + autoscaling_config: + min_replicas: 1 + max_replicas: 5 + target_num_ongoing_requests_per_replica: 4 diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..1d19fa6 --- /dev/null +++ b/src/app.py @@ -0,0 +1,16 @@ +from ray.serve import Application + +from src.core.huri import HuRI +from src.modules.factory import bind_deployment_handles +from src.modules.modules import get_modules + + +def build_app() -> Application: + modules = get_modules() + handles = bind_deployment_handles(modules) + + app: Application = HuRI.bind(modules, handles) # type: ignore[attr-defined] + return app + + +app = build_app() diff --git a/src/launch_huri.py b/src/launch_huri.py index 729ee79..8e7c80a 100644 --- a/src/launch_huri.py +++ b/src/launch_huri.py @@ -2,17 +2,15 @@ import ray -from src.core.huri import Dict, HuRI, handle, serve -from src.modules.speech_to_text.speech_to_text import STTHandle +from src.core.huri import serve + +from .app import build_app def main() -> None: ray.init() - services: Dict[str, handle.DeploymentHandle] = { - "stt": STTHandle.bind(), # type: ignore[attr-defined] - } - app = HuRI.bind("", services) # type: ignore[attr-defined] + app = build_app() time.sleep(0.1) try: serve.run(app, name="HuRI", blocking=True) diff --git a/src/modules/modules.py b/src/modules/modules.py new file mode 100644 index 0000000..11ffbff --- /dev/null +++ b/src/modules/modules.py @@ -0,0 +1,11 @@ +from typing import Dict, Type + +from src.modules.reasoning.embedding import EMB +from src.modules.speech_to_text.record_speech import MIC +from src.modules.speech_to_text.speech_to_text import STT + +from .factory import Module + + +def get_modules() -> Dict[str, Type[Module]]: + return {"mic": MIC, "stt": STT, "emb": EMB} From 5415fddad0558c1407731d650c66ec940f310d0b Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 23 Mar 2026 11:26:11 +0100 Subject: [PATCH 26/65] evol(huri): can now build client config modules and run them --- src/core/app.py | 3 +++ src/core/huri.py | 38 ++++++++++++++++++++++++------------- src/core/session.py | 5 ++++- src/modules/utils/sender.py | 3 ++- 4 files changed, 34 insertions(+), 15 deletions(-) create mode 100644 src/core/app.py diff --git a/src/core/app.py b/src/core/app.py new file mode 100644 index 0000000..9bb71ec --- /dev/null +++ b/src/core/app.py @@ -0,0 +1,3 @@ +from fastapi import FastAPI + +app = FastAPI() diff --git a/src/core/huri.py b/src/core/huri.py index 6883687..6b3a507 100644 --- a/src/core/huri.py +++ b/src/core/huri.py @@ -1,25 +1,29 @@ import uuid -from typing import Dict +from typing import Dict, List, Type -from fastapi import FastAPI, WebSocket +from fastapi import WebSocket from ray import serve from ray.serve import handle -from src.modules.speech_to_text.record_speech import MIC -from src.modules.speech_to_text.speech_to_text import STT +from src.modules.factory import Module, ModuleFactory from src.modules.utils.sender import Sender +from .app import app +from .dataclasses.config import ClientConfig from .session import Session -app = FastAPI() - @serve.deployment @serve.ingress(app) class HuRI: - def __init__(self, config, handles: Dict[str, handle.DeploymentHandle]) -> None: - self.config = config - self.handles = handles + def __init__( + self, + modules: Dict[str, Type[Module]], + handles: Dict[str, handle.DeploymentHandle], + ) -> None: + self.factory = ModuleFactory(handles) + for name, module_cls in modules.items(): + self.factory.register(name, module_cls) self.clients: Dict[str, Session] = {} @@ -27,15 +31,23 @@ def __init__(self, config, handles: Dict[str, handle.DeploymentHandle]) -> None: async def run_session(self, ws: WebSocket): await ws.accept() - modules = [ - STT(self.handles["stt"]), - MIC(5), - Sender(ws, "text"), + client_config_raw: Dict = await ws.receive_json() + + client_config = ClientConfig.from_dict(client_config_raw) + + senders: List[Module] = [ + Sender(ws, topic) for topic in client_config.topic_list ] + modules: List[Module] = ( + self.factory.create_from_config(client_config.modules) + senders + ) + session_id = str(uuid.uuid4()) self.clients[session_id] = Session(modules) + print("Client registered successfully with config:", client_config) + async def receive_loop(session: Session, ws: WebSocket): while True: msg = await ws.receive() diff --git a/src/core/session.py b/src/core/session.py index 85d6712..961d0db 100644 --- a/src/core/session.py +++ b/src/core/session.py @@ -1,8 +1,11 @@ +from typing import List + from .events import EventGraph +from .module import Module class Session: - def __init__(self, modules): + def __init__(self, modules: List[Module]): self.event_graph = EventGraph() for module in modules: diff --git a/src/modules/utils/sender.py b/src/modules/utils/sender.py index e4c45a2..6a40a41 100644 --- a/src/modules/utils/sender.py +++ b/src/modules/utils/sender.py @@ -1,6 +1,7 @@ from typing import Any -from src.core.huri import WebSocket +from fastapi import WebSocket + from src.core.module import Module From 1000fb8ab7d773ca13c8f2b780ba5c9c497f8d4f Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 23 Mar 2026 11:26:58 +0100 Subject: [PATCH 27/65] feat(client): can now send config to huri --- config/client_aux.yaml | 13 +++++++++++++ config/client_io.yaml | 19 +++++++++++++++++++ src/client.py | 28 ++++++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 config/client_aux.yaml create mode 100644 config/client_io.yaml diff --git a/config/client_aux.yaml b/config/client_aux.yaml new file mode 100644 index 0000000..4606eff --- /dev/null +++ b/config/client_aux.yaml @@ -0,0 +1,13 @@ +huri_url: ws://localhost:8000/session + +topic_list: ["text"] + +modules: + mic: + name: mic + args: + threshold: 50 + logging: INFO + stt: + name: stt + logging: INFO diff --git a/config/client_io.yaml b/config/client_io.yaml new file mode 100644 index 0000000..d7bb00f --- /dev/null +++ b/config/client_io.yaml @@ -0,0 +1,19 @@ +huri_url: ws://localhost:8000/session + +topic_list: ["text"] + +modules: + inp: + name: INP + logging: INFO + out: + name: OUT + logging: INFO + mod: + name: MOD + logging: INFO + rag: + name: RAG + args: + model: deepseek-v2:16b + logging: INFO diff --git a/src/client.py b/src/client.py index 6c439ee..4a5cf84 100644 --- a/src/client.py +++ b/src/client.py @@ -1,19 +1,43 @@ +import argparse import asyncio +import json +from dataclasses import asdict import numpy as np import sounddevice as sd import websockets +import yaml + +from src.core.dataclasses.config import ClientConfig + + +def load_client_config(path: str) -> ClientConfig: + with open(path) as f: + raw = yaml.safe_load(f) + + return ClientConfig.from_dict(raw) + -SERVER_URL = "ws://localhost:8000/session" CHUNK_DURATION = 1 SAMPLE_RATE = 16000 async def stream_audio(): + parser = argparse.ArgumentParser(description="Client config") + parser.add_argument( + "--config", + required=True, + help="Path to Client config file (YAML)", + ) + + args = parser.parse_args() + config = load_client_config(args.config) - async with websockets.connect(SERVER_URL) as ws: + async with websockets.connect(config.huri_url) as ws: print("Connected to server") + await ws.send(json.dumps(asdict(config))) + async def receive(ws: websockets.ClientConnection): while True: text = await ws.recv() From 5c641df05fcad488d46ae9a937eb3ba3401e5bee Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 23 Mar 2026 11:27:44 +0100 Subject: [PATCH 28/65] evol(stt): add STTHandle to STT --- src/modules/speech_to_text/speech_to_text.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/modules/speech_to_text/speech_to_text.py b/src/modules/speech_to_text/speech_to_text.py index 7925f3c..0f9d4e7 100644 --- a/src/modules/speech_to_text/speech_to_text.py +++ b/src/modules/speech_to_text/speech_to_text.py @@ -5,10 +5,10 @@ from ray import serve from ray.serve import handle -from src.core.module import Module +from src.core.module import ModuleWithHandle -@serve.deployment(num_replicas=5) +@serve.deployment class STTHandle: def __init__( self, @@ -18,7 +18,7 @@ def __init__( self.model: whisper.Whisper = whisper.load_model(model_name) - async def process(self, audio_array: np.ndarray) -> Optional[Any]: + async def transcribe(self, audio_array: np.ndarray) -> Optional[Any]: result: dict = self.model.transcribe( audio_array.copy(), condition_on_previous_text=False, fp16=False ) @@ -29,12 +29,14 @@ async def process(self, audio_array: np.ndarray) -> Optional[Any]: return result["text"] -class STT(Module): +class STT(ModuleWithHandle): + _handle_cls = STTHandle + input_type = "voice" output_type = "text" - def __init__(self, stt_handle: handle.DeploymentHandle[STTHandle]): - self.stt = stt_handle + def __init__(self, handle: handle.DeploymentHandle[STTHandle]): + super().__init__(handle) self.chunks: List[np.ndarray] = [] self.running = False @@ -44,7 +46,7 @@ async def process(self, audio: np.ndarray) -> Optional[Any]: if self.running is True: return None self.running = True - text = await self.stt.process.remote(np.concatenate(self.chunks, axis=0)) + text = await self.handle.transcribe.remote(np.concatenate(self.chunks, axis=0)) self.chunks.clear() self.running = False return text From 1c748f5975db1f55242bc5c1f5f0bd96c82b61bc Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 23 Mar 2026 11:28:17 +0100 Subject: [PATCH 29/65] evol(Makefile): mypy check untyped defs --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b1c93cf..a373f79 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ lint: black . isort . flake8 . - mypy . + mypy . --check-untyped-defs test: pytest From 3ac16f8bc5e2ff3a0782ebd8acf25c8faf24dd51 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 3 May 2026 19:20:03 +0200 Subject: [PATCH 30/65] remove(huri): useless module EMB --- config/huri.yaml | 2 -- src/modules/modules.py | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/config/huri.yaml b/config/huri.yaml index 6bdca3e..5ef17d6 100644 --- a/config/huri.yaml +++ b/config/huri.yaml @@ -18,8 +18,6 @@ applications: runtime_env: { RAY_COLOR_PREFIX=1 } deployments: - name: HuRI - - name: EMBHandle - num_replicas: 1 - name: STTHandle autoscaling_config: min_replicas: 1 diff --git a/src/modules/modules.py b/src/modules/modules.py index 11ffbff..7e8a420 100644 --- a/src/modules/modules.py +++ b/src/modules/modules.py @@ -1,6 +1,5 @@ from typing import Dict, Type -from src.modules.reasoning.embedding import EMB from src.modules.speech_to_text.record_speech import MIC from src.modules.speech_to_text.speech_to_text import STT @@ -8,4 +7,4 @@ def get_modules() -> Dict[str, Type[Module]]: - return {"mic": MIC, "stt": STT, "emb": EMB} + return {"mic": MIC, "stt": STT} From d943276d92ed047453b3374aea9b9be590b7af2c Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 3 May 2026 19:21:26 +0200 Subject: [PATCH 31/65] evol(requirement): clean requirement --- requirements.txt | 20 +++++++++----------- requirements_modal.txt | 12 ------------ 2 files changed, 9 insertions(+), 23 deletions(-) delete mode 100644 requirements_modal.txt diff --git a/requirements.txt b/requirements.txt index dbc30d0..492a4ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,15 +5,13 @@ flake8 flake8-toml-config pytest -deepfilternet + +# server +numpy +ray[default] + + +# client sounddevice -soundfile -simpleaudio -torch -torchaudio -transformers -langchain-community -langchain-chroma -langchain-ollama -langgraph -openai-whisper @ git+https://github.com/openai/whisper.git +websockets +PyYAML \ No newline at end of file diff --git a/requirements_modal.txt b/requirements_modal.txt deleted file mode 100644 index 98cf207..0000000 --- a/requirements_modal.txt +++ /dev/null @@ -1,12 +0,0 @@ -soundfile -numpy -ollama -SpeechRecognition==3.14.1 -torch -langchain_core>=0.1.36 -langchain>=0.1.17 -langchain_ollama>=0.0.7 -parler_tts==0.2.3 -pydantic<2.0 -transformers==4.46.1 -whisper @ git+https://github.com/zhuzilin/whisper-openvino.git@cc2f13074ff8a0cd43fe3f574285b2308baf55ec From 8e0077ff3ece864bf4012374b1c3137d63fa0510 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 3 May 2026 19:22:36 +0200 Subject: [PATCH 32/65] feat(README): Getting Started, Usage + config template --- README.md | 44 +++++++++++++++++++++++++++++++++++-- config/client_template.yaml | 15 +++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 config/client_template.yaml diff --git a/README.md b/README.md index 59ee7cf..4b7e00f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,43 @@ -# speech-to-speech +# HuRI -1. python3.12 \ No newline at end of file +## Getting Started + +### Prerequisites + +- python 3.11.14 + ```sh + sudo apt install python3.11 + ``` +- pip + ```sh + sudo apt install python3-pip + ``` + +### Installation + +1. Clone the repo + ```sh + git clone https://github.com/Sentience-Robotics/HuRI.git + ``` +2. Install pip packages + ```sh + pip install -r requirements.txt + ``` + +## Usage + +Launch HuRI server: + +```sh +serve run [config_file_path] +``` + +We use ray serve config file, doc [here](https://docs.ray.io/en/latest/serve/configure-serve-deployment.html) + +Launch Client: + +```sh +python -m src.client --config [client_config_file_path] +``` + +We have custom yaml file to define modules to use and how they are initialized, template [here](config/client_template.yaml) diff --git a/config/client_template.yaml b/config/client_template.yaml new file mode 100644 index 0000000..74ae954 --- /dev/null +++ b/config/client_template.yaml @@ -0,0 +1,15 @@ +# HuRI websocket server url +huri_url: ws://localhost:8000/session + +# List of event topic the client will receive +topic_list: ["topic1", "topic2"] + +# Define module custom args +modules: + # module tag can be anything + example: + # module name must be in the list of available module in HuRI's instance (src.modules.modules:get_modules) + name: my_module + # if my_module init with "model", "sample_rate" and "hello" params, they can be customized here + args: + hello: "world" From aab9b7c5dbcc0ae8b819c55fcd798afb8103c923 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 23 Mar 2026 11:37:55 +0100 Subject: [PATCH 33/65] wip(Embedding): pseudo module --- src/modules/reasoning/__init__.py | 0 src/modules/reasoning/embedding.py | 41 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 src/modules/reasoning/__init__.py create mode 100644 src/modules/reasoning/embedding.py diff --git a/src/modules/reasoning/__init__.py b/src/modules/reasoning/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/reasoning/embedding.py b/src/modules/reasoning/embedding.py new file mode 100644 index 0000000..ff14897 --- /dev/null +++ b/src/modules/reasoning/embedding.py @@ -0,0 +1,41 @@ +from typing import Any, Optional + +import numpy as np +from ray import serve +from ray.serve import handle + +from src.core.module import ModuleWithHandle + + +@serve.deployment +class EMBHandle: + def __init__( + self, + model_name: str = "name", + ): + super().__init__() + + self.model = model_name # TODO MVR load embedding model + + async def embbed(self, data_to_embed: str) -> Optional[Any]: + result = self.model + data_to_embed + + return result + + +class EMB(ModuleWithHandle): + _handle_cls = EMBHandle + + input_type = "toembed" + output_type = "embedded" + + def __init__(self, handle: handle.DeploymentHandle[EMBHandle]): + super().__init__(handle) + + self.database = "" + + async def process(self, data_to_embed: np.ndarray) -> Optional[Any]: + embedded = await self.handle.embbed.remote(data_to_embed) + + # TODO write embedding + return embedded From f397775530ab16ec18f67166e48573c47b1578f4 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 3 May 2026 11:31:57 +0200 Subject: [PATCH 34/65] evol(events): error handling --- src/core/events.py | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/core/events.py b/src/core/events.py index 0d3653d..c2f0d0f 100644 --- a/src/core/events.py +++ b/src/core/events.py @@ -18,16 +18,25 @@ async def publish(self, event_topic, data): asyncio.create_task(self._run(module, data)) async def _run(self, module: Module, data): - - result = module.process(data) - - if hasattr(result, "__aiter__"): - async for item in result: - if item is None: - continue - await self.publish(module.output_type, item) - - else: - value = await result - if value is not None: - await self.publish(module.output_type, value) + try: + result = module.process(data) + + if hasattr(result, "__aiter__"): + try: + async for item in result: + if item is None: + continue + await self.publish(module.output_type, item) + except Exception as e: + print(f"[ERROR] async generator in {module}: {e}") + + else: + try: + value = await result + if value is not None: + await self.publish(module.output_type, value) + except Exception as e: + print(f"[ERROR] coroutine in {module}: {e}") + + except Exception as e: + print(f"[ERROR] process() call failed in {module}: {e}") From d57580f015113abc4d12195fae5c0bc33f07db48 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 3 May 2026 11:42:23 +0200 Subject: [PATCH 35/65] evol(MIC): WebRTC vad to detect if speech, no longer a threshold --- src/modules/speech_to_text/record_speech.py | 56 ++++++++++++++++++--- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/src/modules/speech_to_text/record_speech.py b/src/modules/speech_to_text/record_speech.py index 0f28755..c967573 100644 --- a/src/modules/speech_to_text/record_speech.py +++ b/src/modules/speech_to_text/record_speech.py @@ -1,25 +1,67 @@ +from dataclasses import dataclass from typing import Optional import numpy as np +import webrtcvad from src.core.module import Module +@dataclass +class Voice: + data: Optional[np.ndarray] + + class MIC(Module): + """MIC Module + + Detect voice and silence using WebRTC VAD. + + input: chunk, + output: voice + + :vad_agressiveness: from 0 (low) to 3 (high, can distord audio). + :silence_duration: how many seconds will a no voice be considered a silence. + :sample_rate: size of received chunk of audio. usually 8000, 16000 or 48000. + :block_duration: size of received chunk of audio (in s). can only be 0.010, 0.020 and 0.030. + """ + input_type = "chunk" output_type = "voice" def __init__( self, - threshold: int = 0, + vad_agressiveness: int = 3, + silence_duration: float = 3, # s + sample_rate: int = 16000, + block_duration: float = 0.020, # s ): super().__init__() - self.THRESHOLD: int = threshold + if block_duration not in [0.010, 0.020, 0.030]: + raise RuntimeError("block duration must be 0.010, 0.020 or 0.030 s") - async def process(self, data: bytes) -> Optional[np.ndarray]: - audio_array = np.frombuffer(data, dtype=np.int16) - if np.abs(audio_array).mean() > self.THRESHOLD: + self.sample_rate: int = sample_rate + self.block_size: int = int(block_duration * sample_rate) # ms + + self.silence_frames_size: int = int(silence_duration * sample_rate) + self.silence_frames_count: int = -1 + + self.vad = webrtcvad.Vad(vad_agressiveness) + + async def process(self, data: bytes) -> Optional[Voice]: + if self.vad.is_speech(data, self.sample_rate) is True: + self.silence_frames_count = 0 + + audio_array = np.frombuffer(data, dtype=np.int16) audio_array_float = audio_array.astype(np.float32) / 32768.0 - return audio_array_float - return None + + return Voice(audio_array_float) + else: + if self.silence_frames_count != -1: + self.silence_frames_count += self.block_size + if self.silence_frames_count > self.silence_frames_size: + self.silence_frames_count = -1 # sent only once + + return Voice(None) + return None From 9a8f8e2b6182abc6afdf3000fef9167bb1fb47e9 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 3 May 2026 11:56:01 +0200 Subject: [PATCH 36/65] evol(STT): use of faster whisper can transcript in real time + sliding window transcription with end boolean --- src/modules/speech_to_text/speech_to_text.py | 118 +++++++++++++------ 1 file changed, 84 insertions(+), 34 deletions(-) diff --git a/src/modules/speech_to_text/speech_to_text.py b/src/modules/speech_to_text/speech_to_text.py index 0f9d4e7..e689337 100644 --- a/src/modules/speech_to_text/speech_to_text.py +++ b/src/modules/speech_to_text/speech_to_text.py @@ -1,52 +1,102 @@ -from typing import Any, List, Optional +import asyncio +from dataclasses import dataclass +from typing import List, Optional import numpy as np -import whisper -from ray import serve -from ray.serve import handle +from faster_whisper import WhisperModel -from src.core.module import ModuleWithHandle +from src.core.module import Module +from .record_speech import Voice + + +@dataclass +class Transcript: + text: str + end: bool + + +class STT(Module): + """STT Module + + Transcribe voice using Faster_Whisper. + + input: voice, + output: transcript + + :model: size of the model to use (tiny, tiny.en, base, base.en, small, small.en, distil-small.en, medium, medium.en, distil-medium.en, large-v1, large-v2, large-v3, large, distil-large-v2, distil-large-v3, large-v3-turbo, or turbo) + :language: language spoken in the audio. It should be a language code such as "en" or "fr". + :sample_rate: size of received voice audio. usually 8000, 16000 or 48000. + :block_duration: size of received voice audio (in s). + """ + + input_type = "voice" + output_type = "transcript" -@serve.deployment -class STTHandle: def __init__( self, - model_name: str = "base", + model: str = "base", + language: str = "en", + sample_rate: int = 16000, + block_duration: float = 0.020, # s + transcribe_window: float = 2.0, # s + transcribe_step: float = 1.0, # s ): super().__init__() - self.model: whisper.Whisper = whisper.load_model(model_name) + self.model_faster = WhisperModel(model) + self.language = language - async def transcribe(self, audio_array: np.ndarray) -> Optional[Any]: - result: dict = self.model.transcribe( - audio_array.copy(), condition_on_previous_text=False, fp16=False - ) - result["text"] = result["text"].strip() - if not result["text"] or result["text"] == "": - return None + self.sample_rate = sample_rate + self.window_size: int = int(transcribe_window / block_duration) + self.step_size: int = int(transcribe_step / block_duration) - return result["text"] + self.buffer: List[np.ndarray] = [] + self.silence: bool = True -class STT(ModuleWithHandle): - _handle_cls = STTHandle + self.prev_text: str = "" + self.stable_text: str = "" - input_type = "voice" - output_type = "text" + self.running = False + self.lock: asyncio.Lock = asyncio.Lock() - def __init__(self, handle: handle.DeploymentHandle[STTHandle]): - super().__init__(handle) + async def process(self, voice: Voice) -> Optional[Transcript]: + if voice.data is None: + self.silence = True + else: + self.silence = False + async with self.lock: + self.buffer.append(voice.data) - self.chunks: List[np.ndarray] = [] - self.running = False + async with self.lock: + if self.running: + return None + self.running = True - async def process(self, audio: np.ndarray) -> Optional[Any]: - self.chunks.append(audio) - if self.running is True: - return None - self.running = True - text = await self.handle.transcribe.remote(np.concatenate(self.chunks, axis=0)) - self.chunks.clear() - self.running = False - return text + async with self.lock: + buffer_size = len(self.buffer) + if buffer_size == 0 or ( + self.silence is False and buffer_size < self.window_size + ): + self.running = False + return None + processing_chunks = self.buffer[: self.window_size] + + self.pending_silence = False + processing_audio = np.concatenate(processing_chunks, axis=0) + + segments, _ = self.model_faster.transcribe( + processing_audio, + language=self.language, + beam_size=1, # faster for realtime + ) + + current_text = " ".join([seg.text for seg in segments]).strip() + + processed_size = self.window_size - self.step_size + async with self.lock: + self.buffer = self.buffer[processed_size:] + self.running = False + + return Transcript(current_text, self.silence) From e102b0e99263e5e41598d3bbfc99354b7a5b3517 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 3 May 2026 12:00:16 +0200 Subject: [PATCH 37/65] evol(client config): sample rate, frame duration + mic,stt modules args --- config/client_aux.yaml | 11 +++++++++-- src/client.py | 18 ++++++++++-------- src/core/dataclasses/config.py | 4 ++++ 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/config/client_aux.yaml b/config/client_aux.yaml index 4606eff..3858b85 100644 --- a/config/client_aux.yaml +++ b/config/client_aux.yaml @@ -1,13 +1,20 @@ huri_url: ws://localhost:8000/session -topic_list: ["text"] +topic_list: ["transcript"] + +sample_rate: 16000 +frame_duration: 0.030 modules: mic: name: mic args: - threshold: 50 + vad_agressiveness: 3 + silence_duration: 1.5 + block_duration: ${frame_duration} logging: INFO stt: name: stt + args: + voice_block_duration: ${frame_duration} logging: INFO diff --git a/src/client.py b/src/client.py index 4a5cf84..ca29146 100644 --- a/src/client.py +++ b/src/client.py @@ -2,24 +2,25 @@ import asyncio import json from dataclasses import asdict +from typing import Dict import numpy as np import sounddevice as sd import websockets -import yaml +from omegaconf import OmegaConf from src.core.dataclasses.config import ClientConfig def load_client_config(path: str) -> ClientConfig: with open(path) as f: - raw = yaml.safe_load(f) + dict_config = OmegaConf.load(f) + raw_resolved = OmegaConf.to_container(dict_config, resolve=True) - return ClientConfig.from_dict(raw) + if not isinstance(raw_resolved, Dict): + raise RuntimeError("error yaml does not output a dict") - -CHUNK_DURATION = 1 -SAMPLE_RATE = 16000 + return ClientConfig.from_dict(raw_resolved) async def stream_audio(): @@ -33,6 +34,7 @@ async def stream_audio(): args = parser.parse_args() config = load_client_config(args.config) + FRAME_SIZE = int(config.sample_rate * config.frame_duration) async with websockets.connect(config.huri_url) as ws: print("Connected to server") @@ -52,11 +54,11 @@ def callback(indata: np.ndarray, frames, time, status): loop.call_soon_threadsafe(queue.put_nowait, indata.copy()) with sd.InputStream( - samplerate=SAMPLE_RATE, + samplerate=config.sample_rate, channels=1, dtype="int16", callback=callback, - blocksize=int(CHUNK_DURATION * SAMPLE_RATE), + blocksize=FRAME_SIZE, ): while True: chunk = await queue.get() diff --git a/src/core/dataclasses/config.py b/src/core/dataclasses/config.py index 6b76a3e..16d633d 100644 --- a/src/core/dataclasses/config.py +++ b/src/core/dataclasses/config.py @@ -19,6 +19,8 @@ def from_dict(self, raw: dict) -> "ModuleConfig": class ClientConfig: huri_url: str topic_list: List[str] + sample_rate: float + frame_duration: float modules: Dict[str, ModuleConfig] @classmethod @@ -30,5 +32,7 @@ def from_dict(cls, raw: Dict) -> "ClientConfig": return cls( huri_url=raw["huri_url"], topic_list=raw["topic_list"], + sample_rate=raw["sample_rate"], + frame_duration=raw["frame_duration"], modules=modules, ) From 8d5534841005967df00099cb803af249f9b82dcf Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 3 May 2026 12:00:43 +0200 Subject: [PATCH 38/65] evol(sender): now can send json serializable object only --- src/modules/utils/sender.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/modules/utils/sender.py b/src/modules/utils/sender.py index 6a40a41..49e431c 100644 --- a/src/modules/utils/sender.py +++ b/src/modules/utils/sender.py @@ -1,3 +1,4 @@ +from dataclasses import asdict from typing import Any from fastapi import WebSocket @@ -6,7 +7,11 @@ class Sender(Module): - """Module to send output data to the client""" + """Sender Module + + Send output data to the client. This data must be JSON serialisable, like a dataclass. + + input: auto, output: None""" input_type = None output_type = None @@ -17,4 +22,4 @@ def __init__(self, ws: WebSocket, type: str): self.input_type = type async def process(self, data: Any): - await self.ws.send_text(data) + await self.ws.send_json(asdict(data)) From 11f7f7f2a79ec947740255d3fff146b39dbeff00 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 3 May 2026 12:29:43 +0200 Subject: [PATCH 39/65] fix(client config): wrong arg name --- config/client_aux.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/client_aux.yaml b/config/client_aux.yaml index 3858b85..e1d4936 100644 --- a/config/client_aux.yaml +++ b/config/client_aux.yaml @@ -16,5 +16,5 @@ modules: stt: name: stt args: - voice_block_duration: ${frame_duration} + block_duration: ${frame_duration} logging: INFO From 2026836812523a20b53d412ff6a898b69616cad8 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 3 May 2026 12:47:15 +0200 Subject: [PATCH 40/65] feat(TAG): aggregate all transcription into one question --- config/client_aux.yaml | 5 +- src/modules/modules.py | 3 +- src/modules/speech_to_text/text_aggregator.py | 54 +++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 src/modules/speech_to_text/text_aggregator.py diff --git a/config/client_aux.yaml b/config/client_aux.yaml index e1d4936..7856840 100644 --- a/config/client_aux.yaml +++ b/config/client_aux.yaml @@ -1,6 +1,6 @@ huri_url: ws://localhost:8000/session -topic_list: ["transcript"] +topic_list: ["transcript", "question"] sample_rate: 16000 frame_duration: 0.030 @@ -18,3 +18,6 @@ modules: args: block_duration: ${frame_duration} logging: INFO + tag: + name: tag + logging: INFO diff --git a/src/modules/modules.py b/src/modules/modules.py index 7e8a420..69ebb45 100644 --- a/src/modules/modules.py +++ b/src/modules/modules.py @@ -2,9 +2,10 @@ from src.modules.speech_to_text.record_speech import MIC from src.modules.speech_to_text.speech_to_text import STT +from src.modules.speech_to_text.text_aggregator import TAG from .factory import Module def get_modules() -> Dict[str, Type[Module]]: - return {"mic": MIC, "stt": STT} + return {"mic": MIC, "stt": STT, "tag": TAG} diff --git a/src/modules/speech_to_text/text_aggregator.py b/src/modules/speech_to_text/text_aggregator.py new file mode 100644 index 0000000..d64dd42 --- /dev/null +++ b/src/modules/speech_to_text/text_aggregator.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass +from typing import Optional +from .speech_to_text import Transcript + +from src.core.module import Module + +from difflib import SequenceMatcher + + +@dataclass +class Sentence: + text: str + + +class TAG(Module): + """TAG Module + + Aggregate all transcriptions and send when transcript end. + + input: transcript, + output: question + """ + + input_type = "transcript" + output_type = "question" + + def __init__( + self, + ): + super().__init__() + + self.sentence: str = "" + + def _merge(self, current: str, new: str) -> str: + matcher = SequenceMatcher(None, current, new) + match = matcher.find_longest_match(0, len(current), 0, len(new)) + + return current[: match.a] + new[match.b :] + + async def process(self, transcript: Transcript) -> Optional[Transcript]: + text = transcript.text + + if text != "": + if not self.sentence: + self.sentence = text + else: + self.sentence = self._merge(self.sentence, text) + + if transcript.end and self.sentence != "": + sentence = Sentence(self.sentence) + self.sentence = "" + return sentence + else: + return None From 3c2af5c46b95e3a5815f2bf17361b39b050f89dc Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 3 May 2026 13:10:54 +0200 Subject: [PATCH 41/65] fix(linter): make lint --- pyproject.toml | 2 +- src/modules/speech_to_text/record_speech.py | 5 +++-- src/modules/speech_to_text/speech_to_text.py | 10 +++++++--- src/modules/speech_to_text/text_aggregator.py | 6 +++--- src/modules/utils/sender.py | 3 ++- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 476f4a8..72f1390 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ skip_gitignore = true [tool.flake8] max-line-length = 88 -extend-ignore = [] +extend-ignore = ["E203"] exclude = """ __pycache__ venv diff --git a/src/modules/speech_to_text/record_speech.py b/src/modules/speech_to_text/record_speech.py index c967573..740e21b 100644 --- a/src/modules/speech_to_text/record_speech.py +++ b/src/modules/speech_to_text/record_speech.py @@ -22,8 +22,9 @@ class MIC(Module): :vad_agressiveness: from 0 (low) to 3 (high, can distord audio). :silence_duration: how many seconds will a no voice be considered a silence. - :sample_rate: size of received chunk of audio. usually 8000, 16000 or 48000. - :block_duration: size of received chunk of audio (in s). can only be 0.010, 0.020 and 0.030. + :sample_rate: size of received chunk of audio. Usually 8000, 16000 or 48000. + :block_duration: size of received chunk of audio (in s). + Can only be 0.010, 0.020 and 0.030. """ input_type = "chunk" diff --git a/src/modules/speech_to_text/speech_to_text.py b/src/modules/speech_to_text/speech_to_text.py index e689337..011412e 100644 --- a/src/modules/speech_to_text/speech_to_text.py +++ b/src/modules/speech_to_text/speech_to_text.py @@ -24,9 +24,13 @@ class STT(Module): input: voice, output: transcript - :model: size of the model to use (tiny, tiny.en, base, base.en, small, small.en, distil-small.en, medium, medium.en, distil-medium.en, large-v1, large-v2, large-v3, large, distil-large-v2, distil-large-v3, large-v3-turbo, or turbo) - :language: language spoken in the audio. It should be a language code such as "en" or "fr". - :sample_rate: size of received voice audio. usually 8000, 16000 or 48000. + :model: size of the model to use (tiny, tiny.en, base, base.en, small, + small.en, distil-small.en, medium, medium.en, distil-medium.en, + large-v1, large-v2, large-v3, large, distil-large-v2, distil-large-v3, + large-v3-turbo, or turbo). + :language: language spoken in the audio. It should be a language code such + as "en" or "fr". + :sample_rate: size of received voice audio. Usually 8000, 16000 or 48000. :block_duration: size of received voice audio (in s). """ diff --git a/src/modules/speech_to_text/text_aggregator.py b/src/modules/speech_to_text/text_aggregator.py index d64dd42..3f9a716 100644 --- a/src/modules/speech_to_text/text_aggregator.py +++ b/src/modules/speech_to_text/text_aggregator.py @@ -1,10 +1,10 @@ from dataclasses import dataclass +from difflib import SequenceMatcher from typing import Optional -from .speech_to_text import Transcript from src.core.module import Module -from difflib import SequenceMatcher +from .speech_to_text import Transcript @dataclass @@ -37,7 +37,7 @@ def _merge(self, current: str, new: str) -> str: return current[: match.a] + new[match.b :] - async def process(self, transcript: Transcript) -> Optional[Transcript]: + async def process(self, transcript: Transcript) -> Optional[Sentence]: text = transcript.text if text != "": diff --git a/src/modules/utils/sender.py b/src/modules/utils/sender.py index 49e431c..b639a97 100644 --- a/src/modules/utils/sender.py +++ b/src/modules/utils/sender.py @@ -9,7 +9,8 @@ class Sender(Module): """Sender Module - Send output data to the client. This data must be JSON serialisable, like a dataclass. + Send output data to the client. + This data must be JSON serialisable, like a dataclass. input: auto, output: None""" From 0cb10ce7edd1fb91794370824895a6f40c3d2f5b Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 3 May 2026 18:14:41 +0200 Subject: [PATCH 42/65] fix(TAG): some match would destroy sentence --- config/client_aux.yaml | 1 + src/modules/speech_to_text/text_aggregator.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/config/client_aux.yaml b/config/client_aux.yaml index 7856840..72434a0 100644 --- a/config/client_aux.yaml +++ b/config/client_aux.yaml @@ -16,6 +16,7 @@ modules: stt: name: stt args: + language: "fr" block_duration: ${frame_duration} logging: INFO tag: diff --git a/src/modules/speech_to_text/text_aggregator.py b/src/modules/speech_to_text/text_aggregator.py index 3f9a716..345e021 100644 --- a/src/modules/speech_to_text/text_aggregator.py +++ b/src/modules/speech_to_text/text_aggregator.py @@ -30,18 +30,23 @@ def __init__( super().__init__() self.sentence: str = "" + self.prev_index: int = 0 def _merge(self, current: str, new: str) -> str: matcher = SequenceMatcher(None, current, new) match = matcher.find_longest_match(0, len(current), 0, len(new)) + if match.a >= self.prev_index: + self.prev_index = match.a + return current[: match.a] + new[match.b :] - return current[: match.a] + new[match.b :] + self.prev_index = len(current) + return current + new async def process(self, transcript: Transcript) -> Optional[Sentence]: text = transcript.text if text != "": - if not self.sentence: + if self.sentence == "": self.sentence = text else: self.sentence = self._merge(self.sentence, text) @@ -49,6 +54,7 @@ async def process(self, transcript: Transcript) -> Optional[Sentence]: if transcript.end and self.sentence != "": sentence = Sentence(self.sentence) self.sentence = "" + self.prev_index = 0 return sentence else: return None From 71ba721857d3be9833cf4e3d0850205175853641 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 3 May 2026 19:33:33 +0200 Subject: [PATCH 43/65] evol(requirement): new modules --- requirements.txt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 492a4ac..3ea3a7c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,10 +8,12 @@ pytest # server numpy -ray[default] +ray[serve] +webrtcvad +faster-whisper # client sounddevice websockets -PyYAML \ No newline at end of file +omegaconf \ No newline at end of file From 60f9470c43f93a9603fa90967b919fd9e0703e78 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 3 May 2026 19:33:55 +0200 Subject: [PATCH 44/65] evol(config): useless config + handle --- config/client_io.yaml | 19 ------------------- config/huri.yaml | 5 ----- 2 files changed, 24 deletions(-) delete mode 100644 config/client_io.yaml diff --git a/config/client_io.yaml b/config/client_io.yaml deleted file mode 100644 index d7bb00f..0000000 --- a/config/client_io.yaml +++ /dev/null @@ -1,19 +0,0 @@ -huri_url: ws://localhost:8000/session - -topic_list: ["text"] - -modules: - inp: - name: INP - logging: INFO - out: - name: OUT - logging: INFO - mod: - name: MOD - logging: INFO - rag: - name: RAG - args: - model: deepseek-v2:16b - logging: INFO diff --git a/config/huri.yaml b/config/huri.yaml index 5ef17d6..c3545a4 100644 --- a/config/huri.yaml +++ b/config/huri.yaml @@ -18,8 +18,3 @@ applications: runtime_env: { RAY_COLOR_PREFIX=1 } deployments: - name: HuRI - - name: STTHandle - autoscaling_config: - min_replicas: 1 - max_replicas: 5 - target_num_ongoing_requests_per_replica: 4 From 747d1a1a8abce6c2f0f488211da9c579f8c1685c Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 3 May 2026 19:38:55 +0200 Subject: [PATCH 45/65] evol(README): launch_huri without config file --- README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4b7e00f..267c16d 100644 --- a/README.md +++ b/README.md @@ -26,18 +26,24 @@ ## Usage -Launch HuRI server: +#### Launch HuRI server: ```sh serve run [config_file_path] ``` -We use ray serve config file, doc [here](https://docs.ray.io/en/latest/serve/configure-serve-deployment.html) +We use ray serve config file, doc [here](https://docs.ray.io/en/latest/serve/configure-serve-deployment.html). -Launch Client: +You can also launch HuRI without config file: + +```sh +python -m src.launch_huri +``` + +#### Launch Client: ```sh python -m src.client --config [client_config_file_path] ``` -We have custom yaml file to define modules to use and how they are initialized, template [here](config/client_template.yaml) +We have custom yaml file to define modules to use and how they are initialized, template [here](config/client_template.yaml). From 661b1a12a2f64f0d971e3d1917032d8e2ed0be96 Mon Sep 17 00:00:00 2001 From: Kaiser_dev <114906261+MatthiasvonRakowski@users.noreply.github.com> Date: Wed, 6 May 2026 11:35:49 +0100 Subject: [PATCH 46/65] Mvr/#44/connection handle (#13) * wip(Embedding): pseudo module * evol(events): error handling * evol(MIC): WebRTC vad to detect if speech, no longer a threshold * evol(STT): use of faster whisper can transcript in real time + sliding window transcription with end boolean * evol(client config): sample rate, frame duration + mic,stt modules args * evol(sender): now can send json serializable object only * fix(client config): wrong arg name * feat(TAG): aggregate all transcription into one question * fix(linter): make lint * fix(TAG): some match would destroy sentence * evol(requirement): new modules * evol(config): useless config + handle * evol(README): launch_huri without config file * fix(connection): handle errors from client - server connection --------- Co-authored-by: Popochounet --- src/core/huri.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/core/huri.py b/src/core/huri.py index 6b3a507..6d6d747 100644 --- a/src/core/huri.py +++ b/src/core/huri.py @@ -1,7 +1,7 @@ import uuid from typing import Dict, List, Type -from fastapi import WebSocket +from fastapi import WebSocket, WebSocketDisconnect from ray import serve from ray.serve import handle @@ -49,13 +49,15 @@ async def run_session(self, ws: WebSocket): print("Client registered successfully with config:", client_config) async def receive_loop(session: Session, ws: WebSocket): - while True: - msg = await ws.receive() - if "bytes" in msg: - chunk = msg["bytes"] - await session.publish("chunk", chunk) - # else: - # data = msg - # await session.publish(data["type"], data["data"]) - + try: + while True: + msg = await ws.receive() + if "bytes" in msg: + chunk = msg["bytes"] + await session.publish("chunk", chunk) + # else: + # data = msg + # await session.publish(data["type"], data["data"]) + except (WebSocketDisconnect, RuntimeError): + print(f"Client disconnected") await receive_loop(self.clients[session_id], ws) From 592034c607bd7f75036b3f747e423db4d5ed63cb Mon Sep 17 00:00:00 2001 From: Kaiser_dev <114906261+MatthiasvonRakowski@users.noreply.github.com> Date: Mon, 18 May 2026 12:06:01 +0200 Subject: [PATCH 47/65] Mvr/#14/ids managment (#19) * wip(rag): V1 of a rag working with an ollama llm working with the current pipeline with a RAG + Embedding + LLM (ollama). Should work with vLLM but not tested * wip(rag): set the filter at None to be able to restrieve collections without a user_id * feat(id): add ids to make it work with the rag system + an ingestion system * clean(id): clean code * wip(pr): add a module with ids and generate a rag class with module with id and module with handle. Make some refacto : user_id -> _user_id and handle -> _handle --- src/client.py | 37 +++- src/core/huri.py | 21 +- src/core/module.py | 15 +- src/modules/factory.py | 31 ++- src/modules/modules.py | 3 +- src/modules/rag/ingestion.py | 72 +++++++ src/modules/rag/rag.py | 301 +++++++++++++++++++++++++++++ src/modules/reasoning/embedding.py | 6 +- 8 files changed, 453 insertions(+), 33 deletions(-) create mode 100644 src/modules/rag/ingestion.py create mode 100644 src/modules/rag/rag.py diff --git a/src/client.py b/src/client.py index ca29146..63f490a 100644 --- a/src/client.py +++ b/src/client.py @@ -1,6 +1,7 @@ import argparse import asyncio import json +import os from dataclasses import asdict from typing import Dict @@ -12,15 +13,29 @@ from src.core.dataclasses.config import ClientConfig +USER_ID_FILE = os.path.expanduser("~/.huri_user_id") + + +def load_user_id() -> str | None: + if os.path.exists(USER_ID_FILE): + with open(USER_ID_FILE) as f: + return f.read().strip() + return None + + +def save_user_id(_user_id: str): + with open(USER_ID_FILE, "w") as f: + f.write(_user_id) + def load_client_config(path: str) -> ClientConfig: with open(path) as f: dict_config = OmegaConf.load(f) - raw_resolved = OmegaConf.to_container(dict_config, resolve=True) + raw_resolved = OmegaConf.to_container(dict_config, resolve=True) - if not isinstance(raw_resolved, Dict): - raise RuntimeError("error yaml does not output a dict") + if not isinstance(raw_resolved, Dict): + raise RuntimeError("error yaml does not output a dict") - return ClientConfig.from_dict(raw_resolved) + return ClientConfig.from_dict(raw_resolved) async def stream_audio(): @@ -38,7 +53,19 @@ async def stream_audio(): async with websockets.connect(config.huri_url) as ws: print("Connected to server") - await ws.send(json.dumps(asdict(config))) + payload = asdict(config) + _user_id = load_user_id() + if _user_id: + payload["_user_id"] = _user_id + print(f"Reconnecting with _user_id: {_user_id}") + + await ws.send(json.dumps(payload)) + + init_msg = json.loads(await ws.recv()) + if init_msg.get("type") == "session_init": + _user_id = init_msg["_user_id"] + save_user_id(_user_id) + print(f"Session started with _user_id: {_user_id}") async def receive(ws: websockets.ClientConnection): while True: diff --git a/src/core/huri.py b/src/core/huri.py index 6d6d747..4c4207a 100644 --- a/src/core/huri.py +++ b/src/core/huri.py @@ -7,7 +7,6 @@ from src.modules.factory import Module, ModuleFactory from src.modules.utils.sender import Sender - from .app import app from .dataclasses.config import ClientConfig from .session import Session @@ -24,29 +23,28 @@ def __init__( self.factory = ModuleFactory(handles) for name, module_cls in modules.items(): self.factory.register(name, module_cls) - self.clients: Dict[str, Session] = {} @app.websocket("/session") async def run_session(self, ws: WebSocket): await ws.accept() - client_config_raw: Dict = await ws.receive_json() - client_config = ClientConfig.from_dict(client_config_raw) + _user_id = client_config_raw.get("_user_id") or str(uuid.uuid4()) + senders: List[Module] = [ Sender(ws, topic) for topic in client_config.topic_list ] modules: List[Module] = ( - self.factory.create_from_config(client_config.modules) + senders + self.factory.create_from_config(_user_id, client_config.modules) + senders ) - session_id = str(uuid.uuid4()) + await ws.send_json({"type": "session_init", "_user_id": _user_id}) + session_id = str(uuid.uuid4()) self.clients[session_id] = Session(modules) - - print("Client registered successfully with config:", client_config) + print(f"Client registered with _user_id={_user_id}, config: {client_config}") async def receive_loop(session: Session, ws: WebSocket): try: @@ -55,9 +53,8 @@ async def receive_loop(session: Session, ws: WebSocket): if "bytes" in msg: chunk = msg["bytes"] await session.publish("chunk", chunk) - # else: - # data = msg - # await session.publish(data["type"], data["data"]) except (WebSocketDisconnect, RuntimeError): - print(f"Client disconnected") + print(f"Client {_user_id} disconnected") + await receive_loop(self.clients[session_id], ws) + del self.clients[session_id] \ No newline at end of file diff --git a/src/core/module.py b/src/core/module.py index 12543cf..e57dba4 100644 --- a/src/core/module.py +++ b/src/core/module.py @@ -14,6 +14,15 @@ async def process(self, _) -> Optional[Any]: class ModuleWithHandle(Module): _handle_cls: Type[Any] - def __init__(self, handle: handle.DeploymentHandle): - super().__init__() - self.handle = handle + def __init__(self, _handle: handle.DeploymentHandle = None, **kwargs): + super().__init__(**kwargs) + self._handle = _handle + +class ModuleWithId(Module): + def __init__(self, _user_id: str, **kwargs): + super().__init__(**kwargs) + self._user_id = _user_id + + def get_user_context(self) -> dict: + """Override in subclasses to provide user-specific context.""" + return {"_user_id": self._user_id} diff --git a/src/modules/factory.py b/src/modules/factory.py index 39a1a83..d252ab1 100644 --- a/src/modules/factory.py +++ b/src/modules/factory.py @@ -1,7 +1,8 @@ +from os import name from typing import Any, Dict, List, Mapping, Type from src.core.dataclasses.config import ModuleConfig -from src.core.module import Module, ModuleWithHandle, handle +from src.core.module import Module, ModuleWithHandle, handle, ModuleWithId class ModuleFactory: @@ -9,6 +10,7 @@ def __init__(self, handles): self._registry: Dict[str, Type[Module]] = {} self._handles = handles + def register(self, name: str, module_cls: Type[Module]) -> None: if not issubclass(module_cls, Module): raise TypeError(f"{module_cls} must inherit from Module") @@ -19,29 +21,40 @@ def register(self, name: str, module_cls: Type[Module]) -> None: ) self._registry[name] = module_cls - def create(self, name: str, args: Mapping[str, Any] | None = None) -> Module: + + def create( + self, + _user_id: str, + name: str, + args: Mapping[str, Any] | None = None + ) -> Module: + if name not in self._registry: raise ValueError(f"Unknown module '{name}'") + module_cls = self._registry[name] - if args is None: - args = {} + kwargs = dict(args or {}) + if issubclass(module_cls, ModuleWithHandle): if name not in self._handles: raise RuntimeError( f"Handles not bound for '{name}'. Check your config first." ) - return module_cls(handle=self._handles[name], **args) - return module_cls(**args) + kwargs["_handle"] = self._handles[name] + + if issubclass(module_cls, ModuleWithId): + kwargs["_user_id"] = _user_id + + return module_cls(**kwargs) def create_from_config( - self, module_configs: Dict[str, ModuleConfig] + self, _user_id: str, module_configs: Dict[str, ModuleConfig] ) -> List[Module]: modules: List[Module] = [] for _, module_config in module_configs.items(): - modules.append(self.create(module_config.name, module_config.args)) - + modules.append(self.create(_user_id, module_config.name, module_config.args)) if modules == []: raise Exception diff --git a/src/modules/modules.py b/src/modules/modules.py index 69ebb45..7283983 100644 --- a/src/modules/modules.py +++ b/src/modules/modules.py @@ -3,9 +3,10 @@ from src.modules.speech_to_text.record_speech import MIC from src.modules.speech_to_text.speech_to_text import STT from src.modules.speech_to_text.text_aggregator import TAG +from src.modules.rag.rag import RAG from .factory import Module def get_modules() -> Dict[str, Type[Module]]: - return {"mic": MIC, "stt": STT, "tag": TAG} + return {"mic": MIC, "stt": STT, "tag": TAG, "rag": RAG} diff --git a/src/modules/rag/ingestion.py b/src/modules/rag/ingestion.py new file mode 100644 index 0000000..5c458d1 --- /dev/null +++ b/src/modules/rag/ingestion.py @@ -0,0 +1,72 @@ +# ingestion.py +import argparse +import os +import uuid + +from qdrant_client import QdrantClient +from qdrant_client.models import VectorParams, Distance, PointStruct +from sentence_transformers import SentenceTransformer + +USER_ID_FILE = os.path.expanduser("~/.huri_user_id") + + +def get_user_id(provided_id: str = None) -> str: + """Use provided ID, or load from file, or generate new one.""" + if provided_id: + return provided_id + if os.path.exists(USER_ID_FILE): + with open(USER_ID_FILE) as f: + return f.read().strip() + new_id = str(uuid.uuid4()) + with open(USER_ID_FILE, "w") as f: + f.write(new_id) + return new_id + + +def main(): + parser = argparse.ArgumentParser(description="Ingest documents into Qdrant") + parser.add_argument("--user-id", type=str, default=None, help="User ID (reads from ~/.huri_user_id if not provided)") + parser.add_argument("--collection", type=str, default="documents") + parser.add_argument("--qdrant-url", type=str, default="http://localhost:6333") + args = parser.parse_args() + + user_id = get_user_id(args.user_id) + print(f"Ingesting for user_id: {user_id}") + + client = QdrantClient(url=args.qdrant_url) + model = SentenceTransformer("BAAI/bge-large-en-v1.5") + + collections = [c.name for c in client.get_collections().collections] + if args.collection not in collections: + client.create_collection( + collection_name=args.collection, + vectors_config=VectorParams(size=1024, distance=Distance.COSINE), + ) + print(f"Created collection: {args.collection}") + + docs = [ + {"text": "The company budget for 2026 is 2 million euros.", "source": "budget.pdf"}, + {"text": "The project deadline is June 15th 2026.", "source": "planning.pdf"}, + {"text": "The team consists of 5 developers and 2 designers.", "source": "team.pdf"}, + {"text": "The main office is located in Paris, France.", "source": "info.pdf"}, + ] + + points = [] + for doc in docs: + vector = model.encode(doc["text"], normalize_embeddings=True).tolist() + points.append(PointStruct( + id=str(uuid.uuid4()), + vector=vector, + payload={ + "text": doc["text"], + "source": doc["source"], + "user_id": user_id, + }, + )) + + client.upsert(collection_name=args.collection, points=points) + print(f"Ingested {len(points)} documents for user {user_id}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/modules/rag/rag.py b/src/modules/rag/rag.py new file mode 100644 index 0000000..ac594d8 --- /dev/null +++ b/src/modules/rag/rag.py @@ -0,0 +1,301 @@ +from typing import Any, Optional +from dataclasses import dataclass, field + +from ray import serve +from src.core.module import ModuleWithHandle, ModuleWithId, handle +from qdrant_client.models import Filter, FieldCondition, MatchValue +from sentence_transformers import SentenceTransformer +from qdrant_client import QdrantClient + + +import httpx + + +@dataclass +class RAGQuery: + """What flows from RAG module to RAGHandle.""" + _user_id: str + question: str + preferences: dict = field(default_factory=dict) + # preferences can include: language, tone, response_format, max_length, system_prompt, extra_instructions, etc. + + +@dataclass +class RAGResult: + """What RAGHandle returns.""" + answer: str + sources: list[dict] = field(default_factory=list) + + +@serve.deployment( + num_replicas=2, + ray_actor_options={"num_cpus": 1}, +) +class RAGHandle: + """ + Stateless RAG processor. Knows nothing about sessions. + Receives a _user_id + question, uses _user_id to find the right + collection/data in the vector DB, runs embed -> search -> LLM. + """ + + def __init__( + self, + qdrant_url: str = "http://localhost:6333", + default_collection: str = "documents", + embedding_model: str = "BAAI/bge-large-en-v1.5", + llm_provider: str = "ollama", # "vllm", "ollama", "api" + llm_url: str = "http://localhost:11434", + llm_model: str = "mistral:7b", + llm_api_key: str = "", + top_k: int = 5, + score_threshold: float = 0.5, + ): + self.embed_model = SentenceTransformer(embedding_model) + self.qdrant = QdrantClient(url=qdrant_url) + self.default_collection = default_collection + self.top_k = top_k + self.score_threshold = score_threshold + + self.llm_provider = llm_provider + self.llm_url = llm_url + self.llm_model = llm_model + self.llm_api_key = llm_api_key + + def _resolve_user_context(self, _user_id: str) -> tuple[str, dict | None]: + """ + Given a _user_id, decide which collection to search + and which filters to apply. + + Options (pick what fits your data model): + A) One collection per user: collection = f"user_{_user_id}" + B) Shared collection, filter by _user_id in payload + C) Lookup in a DB to find the user's config + """ + + # Option A: separate collection per user + # collection = f"user_{_user_id}" + # filters = None + + # Option B: shared collection with _user_id filter (recommended) + collection = self.default_collection + filters = {"_user_id": _user_id} + + return collection, filters + + + def _embed(self, text) -> list[float]: + return self.embed_model.encode(str(text), normalize_embeddings=True).tolist() + + + + def _search( + self, + query_vector: list[float], + collection: str, + filters: dict | None = None, + ) -> list[dict]: + + qdrant_filter = None + if filters: + conditions = [ + FieldCondition(key=k, match=MatchValue(value=v)) + for k, v in filters.items() + ] + qdrant_filter = Filter(must=conditions) + + results = self.qdrant.query_points( + collection_name=collection, + query=query_vector, + query_filter=qdrant_filter, + limit=self.top_k, + score_threshold=self.score_threshold, + ).points + + return [ + { + "text": point.payload.get("text", ""), + "score": point.score, + "metadata": {k: v for k, v in point.payload.items() if k != "text"}, + } + for point in results + ] + + + def _build_prompt( + self, + question: str, + chunks: list[dict], + preferences: dict, + ) -> tuple[str, str]: + + parts = [ + "You are a robot speaking to a user. Answer based on the provided context.", + "If the context is insufficient, say so clearly.", + ] + if preferences.get("language"): + parts.append(f"Always respond in {preferences['language']}.") + if preferences.get("tone"): + parts.append(f"Use a {preferences['tone']} tone.") + if preferences.get("response_format") == "bullet_points": + parts.append("Format your answer as bullet points.") + elif preferences.get("response_format") == "short": + parts.append("Keep your answer to 2-3 sentences maximum.") + if preferences.get("extra_instructions"): + parts.append(preferences["extra_instructions"]) + system_prompt = " ".join(parts) + + if not chunks: + user_prompt = ( + "No relevant context was found.\n\n" + f"Question: {question}\n\n" + "Answer based on general knowledge." + ) + else: + context_parts = [] + for i, chunk in enumerate(chunks, 1): + source = chunk["metadata"].get("source", "unknown") + context_parts.append( + f"[{i}] (source: {source}, score: {chunk['score']:.2f})\n{chunk['text']}" + ) + context_block = "\n\n".join(context_parts) + user_prompt = ( + f"Context:\n{context_block}\n\n" + f"Question: {question}\n\n" + "Answer based on the context above. Don't speak about the sources, just use them to answer the question." + ) + + return system_prompt, user_prompt + + + async def _llm_generate( + self, + system_prompt: str, + user_prompt: str, + preferences: dict, + ) -> str: + max_tokens = preferences.get("max_length", 1024) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + if self.llm_provider == "vllm": + return await self._call_openai_compatible( + f"{self.llm_url}/v1/chat/completions", messages, max_tokens + ) + elif self.llm_provider == "ollama": + return await self._call_ollama(messages, max_tokens) + elif self.llm_provider == "api": + return await self._call_openai_compatible( + f"{self.llm_url}/v1/chat/completions", messages, max_tokens, self.llm_api_key + ) + else: + raise ValueError(f"Unknown llm_provider: {self.llm_provider}") + + + async def _call_openai_compatible( + self, url: str, messages: list, max_tokens: int, api_key: str = "" + ) -> str: + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post(url, headers=headers, json={ + "model": self.llm_model, + "messages": messages, + "max_tokens": max_tokens, + "temperature": 0.1, + }) + resp.raise_for_status() + return resp.json()["choices"][0]["message"]["content"] + + + async def _call_ollama(self, messages: list, max_tokens: int) -> str: + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post(f"{self.llm_url}/api/chat", json={ + "model": self.llm_model, + "messages": messages, + "stream": False, + "options": {"num_predict": max_tokens, "temperature": 0.1}, + }) + resp.raise_for_status() + return resp.json()["message"]["content"] + + + async def process(self, query: RAGQuery) -> RAGResult: + """ + Main entry point. Called by the RAG module. + Uses _user_id to determine which collection / filters to use. + """ + + print(f"[RAG] Question: {query.question}") + collection, filters = self._resolve_user_context(query._user_id) + query_vector = self._embed(query.question) + chunks = self._search(query_vector, collection, filters) + + + print(f"[RAG] Found {len(chunks)} chunks") + for c in chunks: + print(f" - score: {c['score']:.2f} | {c['text'][:100]}...") + + system_prompt, user_prompt = self._build_prompt( + query.question, chunks, query.preferences + ) + print(f"[RAG] System prompt: {system_prompt[:200]}...") + answer = await self._llm_generate(system_prompt, user_prompt, query.preferences) + print(f"[RAG] Answer: {answer}") + + return RAGResult( + answer=answer, + sources=[ + {"text": c["text"], "score": c["score"], "metadata": c["metadata"]} + for c in chunks + ], + ) + + +class RAG(ModuleWithHandle, ModuleWithId): + _handle_cls = RAGHandle + input_type = "question" + output_type = "rag_response" + + def __init__( + self, + _handle=None, + _user_id="", + language="en", + tone="formal", + response_format="paragraph", + max_length=1024, + extra_instructions="", + **kwargs, + ): + super().__init__(_handle=_handle, _user_id=_user_id, **kwargs) + self.preferences = { + "language": language, + "tone": tone, + "response_format": response_format, + "max_length": max_length, + "extra_instructions": extra_instructions, + } + + async def process(self, data) -> Optional[Any]: + """ + Called when a "question" event arrives through the event bus. + Packages _user_id + question, sends to the stateless RAGHandle. + """ + question_text = data.text if hasattr(data, 'text') else str(data) + + query = RAGQuery( + _user_id=self._user_id if self._user_id else "anonymous", + question=question_text, + preferences=self.preferences, + ) + + result: RAGResult = await self._handle.process.remote(query) + return result + + + def update_preferences(self, new_preferences: dict): + """Client can update preferences mid-session via the event bus.""" + self.preferences.update(new_preferences) diff --git a/src/modules/reasoning/embedding.py b/src/modules/reasoning/embedding.py index ff14897..b6139d9 100644 --- a/src/modules/reasoning/embedding.py +++ b/src/modules/reasoning/embedding.py @@ -29,13 +29,13 @@ class EMB(ModuleWithHandle): input_type = "toembed" output_type = "embedded" - def __init__(self, handle: handle.DeploymentHandle[EMBHandle]): - super().__init__(handle) + def __init__(self, _handle: handle.DeploymentHandle[EMBHandle]): + super().__init__(_handle) self.database = "" async def process(self, data_to_embed: np.ndarray) -> Optional[Any]: - embedded = await self.handle.embbed.remote(data_to_embed) + embedded = await self._handle.embbed.remote(data_to_embed) # TODO write embedding return embedded From 9e089b844e8a0baa49ca9d2bc6a1af0d876d2a43 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 18 May 2026 13:22:34 +0200 Subject: [PATCH 48/65] feat(event): event data are now typed to be validated and serialisable --- src/app.py | 4 +- src/core/events.py | 10 ++++- src/core/huri.py | 44 ++++++++++++++----- src/modules/events.py | 13 ++++++ src/modules/factory.py | 32 +++++++++++++- src/modules/modules.py | 2 +- src/modules/speech_to_text/events.py | 22 ++++++++++ .../{record_speech.py => microphone_vad.py} | 10 ++--- src/modules/speech_to_text/speech_to_text.py | 9 +--- src/modules/speech_to_text/text_aggregator.py | 8 +--- src/modules/utils/sender.py | 11 ++++- 11 files changed, 127 insertions(+), 38 deletions(-) create mode 100644 src/modules/events.py create mode 100644 src/modules/speech_to_text/events.py rename src/modules/speech_to_text/{record_speech.py => microphone_vad.py} (93%) diff --git a/src/app.py b/src/app.py index 1d19fa6..b9060a6 100644 --- a/src/app.py +++ b/src/app.py @@ -1,6 +1,7 @@ from ray.serve import Application from src.core.huri import HuRI +from src.modules.events import get_events from src.modules.factory import bind_deployment_handles from src.modules.modules import get_modules @@ -8,8 +9,9 @@ def build_app() -> Application: modules = get_modules() handles = bind_deployment_handles(modules) + events = get_events() - app: Application = HuRI.bind(modules, handles) # type: ignore[attr-defined] + app: Application = HuRI.bind(modules, handles, events) # type: ignore[attr-defined] return app diff --git a/src/core/events.py b/src/core/events.py index c2f0d0f..18a0505 100644 --- a/src/core/events.py +++ b/src/core/events.py @@ -1,11 +1,19 @@ import asyncio from collections import defaultdict +from src.core.module import Module + from .module import Module -class EventGraph: +class EventData: + """An event data must be derived from this class, and use @dataclass decorator. + Or they can be bytes.""" + + pass + +class EventGraph: def __init__(self): self.subscribers = defaultdict(list) diff --git a/src/core/huri.py b/src/core/huri.py index 6d6d747..9cff006 100644 --- a/src/core/huri.py +++ b/src/core/huri.py @@ -1,11 +1,13 @@ +import json +import struct import uuid -from typing import Dict, List, Type +from typing import Any, Dict, List, Tuple, Type from fastapi import WebSocket, WebSocketDisconnect from ray import serve from ray.serve import handle -from src.modules.factory import Module, ModuleFactory +from src.modules.factory import EventData, EventDataFactory, Module, ModuleFactory from src.modules.utils.sender import Sender from .app import app @@ -20,10 +22,17 @@ def __init__( self, modules: Dict[str, Type[Module]], handles: Dict[str, handle.DeploymentHandle], + events: Dict[str, Type[EventData]], ) -> None: - self.factory = ModuleFactory(handles) + self.module_factory = ModuleFactory(handles) + self.event_factory = EventDataFactory() for name, module_cls in modules.items(): - self.factory.register(name, module_cls) + self.module_factory.register(name, module_cls) + + event_cls = events.pop(module_cls.input_type, None) + self.event_factory.register(module_cls.input_type, event_cls) + event_cls = events.pop(module_cls.output_type, None) + self.event_factory.register(module_cls.output_type, event_cls) self.clients: Dict[str, Session] = {} @@ -39,7 +48,7 @@ async def run_session(self, ws: WebSocket): Sender(ws, topic) for topic in client_config.topic_list ] modules: List[Module] = ( - self.factory.create_from_config(client_config.modules) + senders + self.module_factory.create_from_config(client_config.modules) + senders ) session_id = str(uuid.uuid4()) @@ -52,12 +61,27 @@ async def receive_loop(session: Session, ws: WebSocket): try: while True: msg = await ws.receive() + + if msg["type"] == "websocket.disconnect": + raise WebSocketDisconnect() + if "bytes" in msg: - chunk = msg["bytes"] - await session.publish("chunk", chunk) - # else: - # data = msg - # await session.publish(data["type"], data["data"]) + msg_bytes = msg["bytes"] + topic_len = struct.unpack("!H", msg_bytes[:2])[0] + + topic = msg_bytes[2 : 2 + topic_len].decode() + data = msg_bytes[2 + topic_len :] + else: + msg_text = msg["text"] + event = json.loads(msg_text) + topic = event["topic"] + data = event["data"] + + data = self.event_factory.create(topic, data) + + await session.publish(topic, data) + except (WebSocketDisconnect, RuntimeError): print(f"Client disconnected") + await receive_loop(self.clients[session_id], ws) diff --git a/src/modules/events.py b/src/modules/events.py new file mode 100644 index 0000000..c974b97 --- /dev/null +++ b/src/modules/events.py @@ -0,0 +1,13 @@ +from typing import Dict, Type + +from src.core.events import EventData +from src.modules.speech_to_text.events import Sentence, Transcript, Voice + + +def get_events() -> Dict[str, Type[EventData | bytes]]: + return { + "audio": bytes, + "voice": Voice, + "transcript": Transcript, + "question": Sentence, + } diff --git a/src/modules/factory.py b/src/modules/factory.py index 39a1a83..315a67d 100644 --- a/src/modules/factory.py +++ b/src/modules/factory.py @@ -1,9 +1,39 @@ from typing import Any, Dict, List, Mapping, Type from src.core.dataclasses.config import ModuleConfig +from src.core.events import EventData from src.core.module import Module, ModuleWithHandle, handle +class EventDataFactory: + def __init__(self): + self._registry: Dict[str, Type[EventData | bytes]] = {} + + def register(self, topic: str, event_cls: Type[EventData | bytes] | None) -> None: + if topic in self._registry: + if event_cls is None or event_cls == self._registry[topic]: + return + else: + raise RuntimeError( + f"event data mismatch: {event_cls} and {self._registry[topic]} for event {topic}" + ) + if event_cls is None: + raise RuntimeError(f"event data is not defined for event {topic}") + + self._registry[topic] = event_cls + + def create(self, topic: str, data: Mapping[str, Any] | bytes) -> EventData: + if topic not in self._registry: + raise RuntimeError(f"unknown event topic {topic}") + + event_cls = self._registry[topic] + + if issubclass(event_cls, EventData): + return event_cls(**data) + + return data + + class ModuleFactory: def __init__(self, handles): self._registry: Dict[str, Type[Module]] = {} @@ -39,7 +69,7 @@ def create_from_config( self, module_configs: Dict[str, ModuleConfig] ) -> List[Module]: modules: List[Module] = [] - for _, module_config in module_configs.items(): + for module_config in module_configs.values(): modules.append(self.create(module_config.name, module_config.args)) if modules == []: diff --git a/src/modules/modules.py b/src/modules/modules.py index 69ebb45..557a8ba 100644 --- a/src/modules/modules.py +++ b/src/modules/modules.py @@ -1,6 +1,6 @@ from typing import Dict, Type -from src.modules.speech_to_text.record_speech import MIC +from src.modules.speech_to_text.microphone_vad import MIC from src.modules.speech_to_text.speech_to_text import STT from src.modules.speech_to_text.text_aggregator import TAG diff --git a/src/modules/speech_to_text/events.py b/src/modules/speech_to_text/events.py new file mode 100644 index 0000000..e80f522 --- /dev/null +++ b/src/modules/speech_to_text/events.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass +from typing import Optional + +import numpy as np + +from src.core.events import EventData + + +@dataclass +class Transcript(EventData): + text: str + end: bool + + +@dataclass +class Voice(EventData): + data: Optional[np.ndarray] + + +@dataclass +class Sentence(EventData): + text: str diff --git a/src/modules/speech_to_text/record_speech.py b/src/modules/speech_to_text/microphone_vad.py similarity index 93% rename from src/modules/speech_to_text/record_speech.py rename to src/modules/speech_to_text/microphone_vad.py index 740e21b..ffb82f7 100644 --- a/src/modules/speech_to_text/record_speech.py +++ b/src/modules/speech_to_text/microphone_vad.py @@ -1,4 +1,3 @@ -from dataclasses import dataclass from typing import Optional import numpy as np @@ -6,10 +5,7 @@ from src.core.module import Module - -@dataclass -class Voice: - data: Optional[np.ndarray] +from .events import Voice class MIC(Module): @@ -17,7 +13,7 @@ class MIC(Module): Detect voice and silence using WebRTC VAD. - input: chunk, + input: audio, output: voice :vad_agressiveness: from 0 (low) to 3 (high, can distord audio). @@ -27,7 +23,7 @@ class MIC(Module): Can only be 0.010, 0.020 and 0.030. """ - input_type = "chunk" + input_type = "audio" output_type = "voice" def __init__( diff --git a/src/modules/speech_to_text/speech_to_text.py b/src/modules/speech_to_text/speech_to_text.py index 011412e..1300dd3 100644 --- a/src/modules/speech_to_text/speech_to_text.py +++ b/src/modules/speech_to_text/speech_to_text.py @@ -1,5 +1,4 @@ import asyncio -from dataclasses import dataclass from typing import List, Optional import numpy as np @@ -7,13 +6,7 @@ from src.core.module import Module -from .record_speech import Voice - - -@dataclass -class Transcript: - text: str - end: bool +from .events import Transcript, Voice class STT(Module): diff --git a/src/modules/speech_to_text/text_aggregator.py b/src/modules/speech_to_text/text_aggregator.py index 345e021..72760af 100644 --- a/src/modules/speech_to_text/text_aggregator.py +++ b/src/modules/speech_to_text/text_aggregator.py @@ -1,15 +1,9 @@ -from dataclasses import dataclass from difflib import SequenceMatcher from typing import Optional from src.core.module import Module -from .speech_to_text import Transcript - - -@dataclass -class Sentence: - text: str +from .events import Sentence, Transcript class TAG(Module): diff --git a/src/modules/utils/sender.py b/src/modules/utils/sender.py index b639a97..b46ddaa 100644 --- a/src/modules/utils/sender.py +++ b/src/modules/utils/sender.py @@ -3,6 +3,7 @@ from fastapi import WebSocket +from src.core.events import EventData from src.core.module import Module @@ -22,5 +23,11 @@ def __init__(self, ws: WebSocket, type: str): self.ws: WebSocket = ws self.input_type = type - async def process(self, data: Any): - await self.ws.send_json(asdict(data)) + async def process(self, data: EventData | bytes): + print(data) + if isinstance(data, bytes): + await self.ws.send_bytes(data) + elif isinstance(data, EventData): + await self.ws.send_json(asdict(data)) + else: + await self.ws.send_text(data) From 26dd177c15de4375f0d8ee649637d63659f02a8a Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 18 May 2026 13:25:08 +0200 Subject: [PATCH 49/65] feat(client): client can now send multiple data, must be event data --- requirements.txt | 3 +- src/client.py | 37 ++----------- src/core/client.py | 44 ++++++++++++++++ src/core/client_senders.py | 94 ++++++++++++++++++++++++++++++++++ src/core/dataclasses/config.py | 23 +++++++-- 5 files changed, 163 insertions(+), 38 deletions(-) create mode 100644 src/core/client.py create mode 100644 src/core/client_senders.py diff --git a/requirements.txt b/requirements.txt index 3ea3a7c..5833716 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,4 +16,5 @@ faster-whisper # client sounddevice websockets -omegaconf \ No newline at end of file +omegaconf +prompt-toolkit diff --git a/src/client.py b/src/client.py index ca29146..c81f53c 100644 --- a/src/client.py +++ b/src/client.py @@ -9,6 +9,7 @@ import websockets from omegaconf import OmegaConf +from src.core.client import Client from src.core.dataclasses.config import ClientConfig @@ -23,7 +24,7 @@ def load_client_config(path: str) -> ClientConfig: return ClientConfig.from_dict(raw_resolved) -async def stream_audio(): +async def launch_client(): parser = argparse.ArgumentParser(description="Client config") parser.add_argument( "--config", @@ -34,38 +35,8 @@ async def stream_audio(): args = parser.parse_args() config = load_client_config(args.config) - FRAME_SIZE = int(config.sample_rate * config.frame_duration) - async with websockets.connect(config.huri_url) as ws: - print("Connected to server") - - await ws.send(json.dumps(asdict(config))) - - async def receive(ws: websockets.ClientConnection): - while True: - text = await ws.recv() - print("received:", text) - - async def send(ws: websockets.ClientConnection): - loop = asyncio.get_running_loop() - - queue: asyncio.Queue = asyncio.Queue() - - def callback(indata: np.ndarray, frames, time, status): - loop.call_soon_threadsafe(queue.put_nowait, indata.copy()) - - with sd.InputStream( - samplerate=config.sample_rate, - channels=1, - dtype="int16", - callback=callback, - blocksize=FRAME_SIZE, - ): - while True: - chunk = await queue.get() - await ws.send(chunk.tobytes()) - - await asyncio.gather(receive(ws), send(ws)) + await Client(config=config).run() if __name__ == "__main__": - asyncio.run(stream_audio()) + asyncio.run(launch_client()) diff --git a/src/core/client.py b/src/core/client.py new file mode 100644 index 0000000..b3dd106 --- /dev/null +++ b/src/core/client.py @@ -0,0 +1,44 @@ +import asyncio +import json +from dataclasses import asdict +from typing import Dict, List, Type + +import websockets + +from src.core.dataclasses.config import ClientConfig + +from .client_senders import ClientSender, get_senders + + +class Client: + """Client is init with a Config, and connects to HuRI using websockets""" + + def __init__( + self, + config: ClientConfig, + senders_dict: Dict[str, Type[ClientSender]] = get_senders(), + ): + self.config = config + self.senders_dict = senders_dict + + async def _receive_loop(self, ws: websockets.ClientConnection): + while True: + text = await ws.recv() + print("<<", text) + await asyncio.sleep(0.1) + + async def run(self): + async with websockets.connect(self.config.huri_url) as ws: + print("Connected to server") + + inputs: List[ClientSender] = [ + self.senders_dict[config.name](ws=ws, **config.args) + for config in self.config.inputs.values() + ] + + await ws.send(json.dumps(asdict(self.config))) + + await asyncio.gather( + *(inp.input_loop() for inp in inputs), + self._receive_loop(ws), + ) diff --git a/src/core/client_senders.py b/src/core/client_senders.py new file mode 100644 index 0000000..4fe2a9d --- /dev/null +++ b/src/core/client_senders.py @@ -0,0 +1,94 @@ +import argparse +import asyncio +import json +import struct +from dataclasses import asdict, dataclass, is_dataclass +from typing import Any, Dict, List, Optional, Type + +import numpy as np +import sounddevice as sd +import websockets +from omegaconf import OmegaConf +from prompt_toolkit import PromptSession +from prompt_toolkit.patch_stdout import patch_stdout + +from src.core.dataclasses.config import ClientConfig +from src.core.events import EventData +from src.modules.speech_to_text.events import Sentence + + +class ClientSender: + """This class abstract sending data to HuRI. + + output_type: is the topic that the ClientSender will send. Data structure must match event topic. + + Class derived from ClientSender must implement input_loop, and use ClientSender.send to send data to HuRI. It can be EventData or bytes + """ + + output_type: str + + def __init__(self, ws: websockets.ClientConnection): + self.ws = ws + + async def input_loop(self): + raise NotImplementedError + + async def send(self, topic: str, data: EventData | bytes): + if isinstance(data, EventData): + packet = json.dumps({"topic": topic, "data": asdict(data)}) + else: + topic_bytes = topic.encode() + + packet = struct.pack("!H", len(topic_bytes)) + topic_bytes + data + + await self.ws.send(packet) + + +class AudioSender(ClientSender): + output_type = "audio" + + def __init__( + self, sample_rate: int = 16000, frame_duration: float = 0.030, **kwargs + ): + super().__init__(**kwargs) + + self.sample_rate = sample_rate + self.frame_size = int(sample_rate * frame_duration) + + async def input_loop(self): + loop = asyncio.get_running_loop() + + queue: asyncio.Queue[np.ndarray] = asyncio.Queue() + + def callback(indata: np.ndarray, frames, time, status): + loop.call_soon_threadsafe(queue.put_nowait, indata.copy()) + + with sd.InputStream( + samplerate=self.sample_rate, + channels=1, + dtype="int16", + callback=callback, + blocksize=self.frame_size, + ): + while True: + chunk = await queue.get() + await self.send(self.output_type, chunk.tobytes()) + + +class TextSender(ClientSender): + output_type = "question" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + async def input_loop(self): + session = PromptSession() + while True: + with patch_stdout(): + text = await session.prompt_async(">> ") + + await self.send(self.output_type, Sentence(text)) + + +def get_senders() -> Dict[str, Type[ClientSender]]: + return {"audio": AudioSender, "text": TextSender} diff --git a/src/core/dataclasses/config.py b/src/core/dataclasses/config.py index 16d633d..cc87176 100644 --- a/src/core/dataclasses/config.py +++ b/src/core/dataclasses/config.py @@ -15,16 +15,32 @@ def from_dict(self, raw: dict) -> "ModuleConfig": ) +@dataclass +class ClientSenderConfig: + name: str + args: Mapping[str, Any] + + @classmethod + def from_dict(self, raw: dict) -> "ClientSenderConfig": + return self( + name=raw["name"], + args=raw.get("args", {}), + ) + + @dataclass class ClientConfig: huri_url: str topic_list: List[str] - sample_rate: float - frame_duration: float + senders: Dict[str, ClientSenderConfig] modules: Dict[str, ModuleConfig] @classmethod def from_dict(cls, raw: Dict) -> "ClientConfig": + senders = { + sender_id: ClientSenderConfig.from_dict(mod_raw) + for sender_id, mod_raw in raw.get("senders", {}).items() + } modules = { module_id: ModuleConfig.from_dict(mod_raw) for module_id, mod_raw in raw.get("modules", {}).items() @@ -32,7 +48,6 @@ def from_dict(cls, raw: Dict) -> "ClientConfig": return cls( huri_url=raw["huri_url"], topic_list=raw["topic_list"], - sample_rate=raw["sample_rate"], - frame_duration=raw["frame_duration"], + senders=senders, modules=modules, ) From c3fac886aa248d71d5f2175a5cc682615676dba6 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 18 May 2026 13:25:30 +0200 Subject: [PATCH 50/65] evol(config): update client config --- config/client_aux.yaml | 16 ++++++++++------ config/client_auxio.yaml | 30 ++++++++++++++++++++++++++++++ config/client_template.yaml | 16 +++++++++++++--- config/client_text.yaml | 12 ++++++++++++ 4 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 config/client_auxio.yaml create mode 100644 config/client_text.yaml diff --git a/config/client_aux.yaml b/config/client_aux.yaml index 72434a0..fe3e332 100644 --- a/config/client_aux.yaml +++ b/config/client_aux.yaml @@ -1,9 +1,13 @@ huri_url: ws://localhost:8000/session -topic_list: ["transcript", "question"] +topic_list: [question] -sample_rate: 16000 -frame_duration: 0.030 +senders: + audio: + name: audio + args: + sample_rate: 16000 + frame_duration: 0.030 modules: mic: @@ -11,13 +15,13 @@ modules: args: vad_agressiveness: 3 silence_duration: 1.5 - block_duration: ${frame_duration} + block_duration: ${inputs.audio.args.frame_duration} logging: INFO stt: name: stt args: - language: "fr" - block_duration: ${frame_duration} + language: "en" + block_duration: ${inputs.audio.args.frame_duration} logging: INFO tag: name: tag diff --git a/config/client_auxio.yaml b/config/client_auxio.yaml new file mode 100644 index 0000000..18bfb2e --- /dev/null +++ b/config/client_auxio.yaml @@ -0,0 +1,30 @@ +huri_url: ws://localhost:8000/session + +topic_list: [question] + +senders: + audio: + name: audio + args: + sample_rate: 16000 + frame_duration: 0.030 + text: + name: text + +modules: + mic: + name: mic + args: + vad_agressiveness: 3 + silence_duration: 1.5 + block_duration: ${inputs.audio.args.frame_duration} + logging: INFO + stt: + name: stt + args: + language: en + block_duration: ${inputs.audio.args.frame_duration} + logging: INFO + tag: + name: tag + logging: INFO diff --git a/config/client_template.yaml b/config/client_template.yaml index 74ae954..cf1627d 100644 --- a/config/client_template.yaml +++ b/config/client_template.yaml @@ -2,9 +2,19 @@ huri_url: ws://localhost:8000/session # List of event topic the client will receive -topic_list: ["topic1", "topic2"] +topic_list: [topic1, topic2] -# Define module custom args +# Define senders to be used and their custom args +senders: + # sender tag can be anything + example: + # sender name must be in the list of available ClientSender in Client instance (src.client_sender:get_senders) + name: my_sender + # if my_sender init with "model", "sample_rate" and "refresh_rate" params, they can be customized here + args: + refresh_rate: infinite + +# Define module to be used and their custom args modules: # module tag can be anything example: @@ -12,4 +22,4 @@ modules: name: my_module # if my_module init with "model", "sample_rate" and "hello" params, they can be customized here args: - hello: "world" + hello: world diff --git a/config/client_text.yaml b/config/client_text.yaml new file mode 100644 index 0000000..319e871 --- /dev/null +++ b/config/client_text.yaml @@ -0,0 +1,12 @@ +huri_url: ws://localhost:8000/session + +topic_list: [question] + +senders: + text: + name: text + +modules: + tag: + name: tag + logging: INFO From fffc64629d4134f125a5e2b94ffb46a9ebe0eb2d Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 18 May 2026 13:48:57 +0200 Subject: [PATCH 51/65] fix(linting): make lint --- src/client.py | 5 ----- src/core/client.py | 6 +++--- src/core/client_senders.py | 16 ++++++++-------- src/core/events.py | 4 ++-- src/core/huri.py | 6 ++++-- src/core/module.py | 2 +- src/modules/factory.py | 23 +++++++++++++++-------- src/modules/utils/sender.py | 2 -- 8 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/client.py b/src/client.py index c81f53c..03772ed 100644 --- a/src/client.py +++ b/src/client.py @@ -1,12 +1,7 @@ import argparse import asyncio -import json -from dataclasses import asdict from typing import Dict -import numpy as np -import sounddevice as sd -import websockets from omegaconf import OmegaConf from src.core.client import Client diff --git a/src/core/client.py b/src/core/client.py index b3dd106..656ea6f 100644 --- a/src/core/client.py +++ b/src/core/client.py @@ -31,14 +31,14 @@ async def run(self): async with websockets.connect(self.config.huri_url) as ws: print("Connected to server") - inputs: List[ClientSender] = [ + senders: List[ClientSender] = [ self.senders_dict[config.name](ws=ws, **config.args) - for config in self.config.inputs.values() + for config in self.config.senders.values() ] await ws.send(json.dumps(asdict(self.config))) await asyncio.gather( - *(inp.input_loop() for inp in inputs), + *(sender.input_loop() for sender in senders), self._receive_loop(ws), ) diff --git a/src/core/client_senders.py b/src/core/client_senders.py index 4fe2a9d..163269f 100644 --- a/src/core/client_senders.py +++ b/src/core/client_senders.py @@ -1,18 +1,15 @@ -import argparse import asyncio import json import struct -from dataclasses import asdict, dataclass, is_dataclass -from typing import Any, Dict, List, Optional, Type +from dataclasses import asdict +from typing import Dict, Type import numpy as np import sounddevice as sd import websockets -from omegaconf import OmegaConf from prompt_toolkit import PromptSession from prompt_toolkit.patch_stdout import patch_stdout -from src.core.dataclasses.config import ClientConfig from src.core.events import EventData from src.modules.speech_to_text.events import Sentence @@ -20,9 +17,11 @@ class ClientSender: """This class abstract sending data to HuRI. - output_type: is the topic that the ClientSender will send. Data structure must match event topic. + output_type: is the topic that the ClientSender will send. + Data structure must match event topic. - Class derived from ClientSender must implement input_loop, and use ClientSender.send to send data to HuRI. It can be EventData or bytes + Class derived from ClientSender must implement input_loop, + and use ClientSender.send to send data to HuRI. It can be EventData or bytes """ output_type: str @@ -34,6 +33,7 @@ async def input_loop(self): raise NotImplementedError async def send(self, topic: str, data: EventData | bytes): + packet: str | bytes if isinstance(data, EventData): packet = json.dumps({"topic": topic, "data": asdict(data)}) else: @@ -82,7 +82,7 @@ def __init__(self, **kwargs): super().__init__(**kwargs) async def input_loop(self): - session = PromptSession() + session: PromptSession = PromptSession() while True: with patch_stdout(): text = await session.prompt_async(">> ") diff --git a/src/core/events.py b/src/core/events.py index 18a0505..e12a66e 100644 --- a/src/core/events.py +++ b/src/core/events.py @@ -1,11 +1,11 @@ import asyncio from collections import defaultdict - -from src.core.module import Module +from dataclasses import dataclass from .module import Module +@dataclass class EventData: """An event data must be derived from this class, and use @dataclass decorator. Or they can be bytes.""" diff --git a/src/core/huri.py b/src/core/huri.py index 9cff006..75f8b9f 100644 --- a/src/core/huri.py +++ b/src/core/huri.py @@ -1,7 +1,7 @@ import json import struct import uuid -from typing import Any, Dict, List, Tuple, Type +from typing import Dict, List, Type from fastapi import WebSocket, WebSocketDisconnect from ray import serve @@ -31,6 +31,8 @@ def __init__( event_cls = events.pop(module_cls.input_type, None) self.event_factory.register(module_cls.input_type, event_cls) + if module_cls.output_type is None: + continue event_cls = events.pop(module_cls.output_type, None) self.event_factory.register(module_cls.output_type, event_cls) @@ -82,6 +84,6 @@ async def receive_loop(session: Session, ws: WebSocket): await session.publish(topic, data) except (WebSocketDisconnect, RuntimeError): - print(f"Client disconnected") + print(f"Client disconnected: {session_id}") await receive_loop(self.clients[session_id], ws) diff --git a/src/core/module.py b/src/core/module.py index 12543cf..4340916 100644 --- a/src/core/module.py +++ b/src/core/module.py @@ -4,7 +4,7 @@ class Module: - input_type: Optional[str] + input_type: str output_type: Optional[str] async def process(self, _) -> Optional[Any]: diff --git a/src/modules/factory.py b/src/modules/factory.py index 315a67d..53b759d 100644 --- a/src/modules/factory.py +++ b/src/modules/factory.py @@ -14,24 +14,31 @@ def register(self, topic: str, event_cls: Type[EventData | bytes] | None) -> Non if event_cls is None or event_cls == self._registry[topic]: return else: - raise RuntimeError( - f"event data mismatch: {event_cls} and {self._registry[topic]} for event {topic}" - ) + raise RuntimeError(f"event data mismatch: \ +{event_cls} and {self._registry[topic]} for event {topic}") if event_cls is None: raise RuntimeError(f"event data is not defined for event {topic}") self._registry[topic] = event_cls - def create(self, topic: str, data: Mapping[str, Any] | bytes) -> EventData: + def create(self, topic: str, data: Mapping[str, Any] | bytes) -> EventData | bytes: if topic not in self._registry: raise RuntimeError(f"unknown event topic {topic}") event_cls = self._registry[topic] + if isinstance(data, bytes): + if isinstance(event_cls, bytes): + return data + else: + raise RuntimeError(f"mismatched event data type: \ +{event_cls} is not bytes but should be.") - if issubclass(event_cls, EventData): - return event_cls(**data) - - return data + else: + if isinstance(event_cls, EventData): + return event_cls(**data) + else: + raise RuntimeError(f"mismatched event data type: \ +{event_cls} is not EventData but should be.") class ModuleFactory: diff --git a/src/modules/utils/sender.py b/src/modules/utils/sender.py index b46ddaa..155303b 100644 --- a/src/modules/utils/sender.py +++ b/src/modules/utils/sender.py @@ -1,5 +1,4 @@ from dataclasses import asdict -from typing import Any from fastapi import WebSocket @@ -15,7 +14,6 @@ class Sender(Module): input: auto, output: None""" - input_type = None output_type = None def __init__(self, ws: WebSocket, type: str): From 5e74669c14f0ecbe1640cc3cacd523c1c606dd7a Mon Sep 17 00:00:00 2001 From: Kaiser_dev <114906261+MatthiasvonRakowski@users.noreply.github.com> Date: Fri, 22 May 2026 17:36:23 +0200 Subject: [PATCH 52/65] Mvr/#17/launch docker (#21) * wip(rag): V1 of a rag working with an ollama llm working with the current pipeline with a RAG + Embedding + LLM (ollama). Should work with vLLM but not tested * wip(rag): set the filter at None to be able to restrieve collections without a user_id * feat(id): add ids to make it work with the rag system + an ingestion system * clean(id): clean code * feat(ingestion): ingestion done with the possibility of semantic and word base ingestion. * wip(todo): Add some todos to not forget the work I have to do * refacto(user_ids): user_id -> user_id * wip(pr): add a module with ids and generate a rag class with module with id and module with handle. Make some refacto : user_id -> _user_id and handle -> _handle * wip(docker): add a docker that launch with one commande. Only work with Ollama for other provider we need to change the code. * wip(config): move qdrant, ollama into a config file. * wip(config): config file client updated * feat(huri): update config file * fix(ingestion): update for the wrong branch now fixed * merge: dev -> launch docker * fix(config): huri.yaml fix * remove(main): remove unnecessary function * wip(lint): cleaner code with a make lint * update(requierements): update requierements.txt * delete(reasoning): Reasoning for later --- config/client_aux2.yaml | 26 ++ config/huri.yaml | 15 + requirements.txt | 5 +- src/app.py | 36 ++- src/client.py | 2 +- src/core/huri.py | 3 +- src/core/module.py | 3 +- src/modules/factory.py | 27 +- src/modules/modules.py | 2 +- src/modules/rag/docker_services.py | 240 +++++++++++++++ src/modules/rag/ingestion.py | 462 +++++++++++++++++++++++++--- src/modules/rag/rag.py | 187 ++++++----- src/modules/rag/semantic_chunker.py | 251 +++++++++++++++ src/modules/reasoning/__init__.py | 0 src/modules/reasoning/embedding.py | 41 --- 15 files changed, 1121 insertions(+), 179 deletions(-) create mode 100644 config/client_aux2.yaml create mode 100644 src/modules/rag/docker_services.py create mode 100644 src/modules/rag/semantic_chunker.py delete mode 100644 src/modules/reasoning/__init__.py delete mode 100644 src/modules/reasoning/embedding.py diff --git a/config/client_aux2.yaml b/config/client_aux2.yaml new file mode 100644 index 0000000..09595b4 --- /dev/null +++ b/config/client_aux2.yaml @@ -0,0 +1,26 @@ +huri_url: ws://localhost:8000/session + +topic_list: ["transcript", "question", "rag_response"] +sample_rate: 16000 +frame_duration: 0.030 +modules: + mic: + name: mic + args: + vad_agressiveness: 3 + silence_duration: 1.5 + block_duration: ${frame_duration} + stt: + name: stt + args: + language: "en" + block_duration: ${frame_duration} + logging: INFO + tag: + name: tag + logging: INFO + rag: + name: rag + args: + language: "en" + tone: "formal" diff --git a/config/huri.yaml b/config/huri.yaml index c3545a4..70d2cc7 100644 --- a/config/huri.yaml +++ b/config/huri.yaml @@ -11,6 +11,17 @@ logging_config: enable_access_log: true additional_log_standard_attrs: [] +services: + qdrant: + port: 6333 + image: "qdrant/qdrant:latest" + storage_volume: "qdrant_data" + ollama: + model: "mistral:7b" + image: "ollama/ollama:rocm" + gpu_devices: true + num_replicas: 1 + applications: - name: huri-app route_prefix: / @@ -18,3 +29,7 @@ applications: runtime_env: { RAY_COLOR_PREFIX=1 } deployments: - name: HuRI + - name: RAGHandle + num_replicas: 2 + - name: OllamaService + - name: QdrantService diff --git a/requirements.txt b/requirements.txt index 3ea3a7c..a8ef49d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,9 +11,12 @@ numpy ray[serve] webrtcvad faster-whisper +qdrant-client +sentence-transformers # client sounddevice websockets -omegaconf \ No newline at end of file +omegaconf + diff --git a/src/app.py b/src/app.py index 1d19fa6..e1ae4e3 100644 --- a/src/app.py +++ b/src/app.py @@ -1,14 +1,48 @@ +from pathlib import Path +from typing import Any + +import yaml from ray.serve import Application from src.core.huri import HuRI from src.modules.factory import bind_deployment_handles from src.modules.modules import get_modules +from src.modules.rag.docker_services import OllamaService, QdrantService + + +def load_services_config() -> Any: + config_path = Path(__file__).resolve().parents[1] / "config" / "huri.yaml" + with open(config_path) as f: + config = yaml.safe_load(f) + return config.get("services", {}) + + +def build_qdrant(config: dict) -> Any: + return QdrantService.bind( # type: ignore[attr-defined] + port=config.get("port", 6333), + image=config.get("image", "qdrant/qdrant:latest"), + storage_volume=config.get("storage_volume", "qdrant_data"), + ) + + +def build_ollama(config: dict) -> Any: + return OllamaService.options( # type: ignore[attr-defined] + num_replicas=config.get("num_replicas", 1), + ).bind( + model=config.get("model", "mistral:7b"), + image=config.get("image", "ollama/ollama:latest"), + gpu_devices=config.get("gpu_devices", False), + ) def build_app() -> Application: modules = get_modules() - handles = bind_deployment_handles(modules) + services_config = load_services_config() + + qdrant = build_qdrant(services_config.get("qdrant", {})) + ollama = build_ollama(services_config.get("ollama", {})) + handles = bind_deployment_handles(modules, ollama=ollama, qdrant=qdrant) app: Application = HuRI.bind(modules, handles) # type: ignore[attr-defined] return app diff --git a/src/client.py b/src/client.py index 63f490a..b34bb59 100644 --- a/src/client.py +++ b/src/client.py @@ -12,7 +12,6 @@ from src.core.dataclasses.config import ClientConfig - USER_ID_FILE = os.path.expanduser("~/.huri_user_id") @@ -27,6 +26,7 @@ def save_user_id(_user_id: str): with open(USER_ID_FILE, "w") as f: f.write(_user_id) + def load_client_config(path: str) -> ClientConfig: with open(path) as f: dict_config = OmegaConf.load(f) diff --git a/src/core/huri.py b/src/core/huri.py index 4c4207a..70dccb6 100644 --- a/src/core/huri.py +++ b/src/core/huri.py @@ -7,6 +7,7 @@ from src.modules.factory import Module, ModuleFactory from src.modules.utils.sender import Sender + from .app import app from .dataclasses.config import ClientConfig from .session import Session @@ -57,4 +58,4 @@ async def receive_loop(session: Session, ws: WebSocket): print(f"Client {_user_id} disconnected") await receive_loop(self.clients[session_id], ws) - del self.clients[session_id] \ No newline at end of file + del self.clients[session_id] diff --git a/src/core/module.py b/src/core/module.py index e57dba4..e917344 100644 --- a/src/core/module.py +++ b/src/core/module.py @@ -14,10 +14,11 @@ async def process(self, _) -> Optional[Any]: class ModuleWithHandle(Module): _handle_cls: Type[Any] - def __init__(self, _handle: handle.DeploymentHandle = None, **kwargs): + def __init__(self, _handle: handle.DeploymentHandle | None = None, **kwargs): super().__init__(**kwargs) self._handle = _handle + class ModuleWithId(Module): def __init__(self, _user_id: str, **kwargs): super().__init__(**kwargs) diff --git a/src/modules/factory.py b/src/modules/factory.py index d252ab1..bb6793e 100644 --- a/src/modules/factory.py +++ b/src/modules/factory.py @@ -1,8 +1,7 @@ -from os import name from typing import Any, Dict, List, Mapping, Type from src.core.dataclasses.config import ModuleConfig -from src.core.module import Module, ModuleWithHandle, handle, ModuleWithId +from src.core.module import Module, ModuleWithHandle, ModuleWithId, handle class ModuleFactory: @@ -10,7 +9,6 @@ def __init__(self, handles): self._registry: Dict[str, Type[Module]] = {} self._handles = handles - def register(self, name: str, module_cls: Type[Module]) -> None: if not issubclass(module_cls, Module): raise TypeError(f"{module_cls} must inherit from Module") @@ -21,13 +19,9 @@ def register(self, name: str, module_cls: Type[Module]) -> None: ) self._registry[name] = module_cls - def create( - self, - _user_id: str, - name: str, - args: Mapping[str, Any] | None = None - ) -> Module: + self, _user_id: str, name: str, args: Mapping[str, Any] | None = None + ) -> Module: if name not in self._registry: raise ValueError(f"Unknown module '{name}'") @@ -54,7 +48,9 @@ def create_from_config( ) -> List[Module]: modules: List[Module] = [] for _, module_config in module_configs.items(): - modules.append(self.create(_user_id, module_config.name, module_config.args)) + modules.append( + self.create(_user_id, module_config.name, module_config.args) + ) if modules == []: raise Exception @@ -63,6 +59,7 @@ def create_from_config( def bind_deployment_handles( modules: Dict[str, Type[Module]], + **service_handles, ) -> Dict[str, handle.DeploymentHandle]: handles: Dict[str, handle.DeploymentHandle] = {} for name, module_cls in modules.items(): @@ -71,7 +68,15 @@ def bind_deployment_handles( if not hasattr(module_cls, "_handle_cls"): raise TypeError(f"{module_cls.__name__} must define _handle_cls") + handle_cls = module_cls._handle_cls - handles[name] = handle_cls.bind() + + if name == "rag" and service_handles: + handles[name] = handle_cls.bind( + ollama_handle=service_handles.get("ollama"), + qdrant_handle=service_handles.get("qdrant"), + ) + else: + handles[name] = handle_cls.bind() return handles diff --git a/src/modules/modules.py b/src/modules/modules.py index 7283983..a6a8d94 100644 --- a/src/modules/modules.py +++ b/src/modules/modules.py @@ -1,9 +1,9 @@ from typing import Dict, Type +from src.modules.rag.rag import RAG from src.modules.speech_to_text.record_speech import MIC from src.modules.speech_to_text.speech_to_text import STT from src.modules.speech_to_text.text_aggregator import TAG -from src.modules.rag.rag import RAG from .factory import Module diff --git a/src/modules/rag/docker_services.py b/src/modules/rag/docker_services.py new file mode 100644 index 0000000..cc9f4b5 --- /dev/null +++ b/src/modules/rag/docker_services.py @@ -0,0 +1,240 @@ +import socket +import subprocess +import time +from typing import Any + +import httpx +from ray import serve + + +def find_free_port() -> Any: + """ + Ask the OS for a random free port. + We need this because if we run multiple Ollama containers, + they can't all use port 11434 — each needs its own. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +def wait_for_service(url: str, timeout: int = 120) -> bool: + """ + Returns True if ready, False if timeout. + """ + start = time.time() + while time.time() - start < timeout: + try: + resp = httpx.get(url, timeout=5) + if resp.status_code == 200: + return True + except Exception: + pass + time.sleep(2) + return False + + +def is_container_running(name: str) -> bool: + """Check if a Docker container with this name is already running.""" + result = subprocess.run( + ["docker", "ps", "-q", "-f", f"name=^{name}$"], + capture_output=True, + text=True, + ) + return bool(result.stdout.strip()) + + +def remove_container(name: str): + """Force remove a container by name (ignores errors if it doesn't exist).""" + subprocess.run(["docker", "rm", "-f", name], capture_output=True) + + +@serve.deployment +class OllamaService: + """ + Manages one Ollama Docker container. + + LIFECYCLE: + __init__: starts container -> waits for it -> pulls model + generate: sends a prompt to the container, returns the answer + __del__: stops and removes the container + """ + + def __init__( + self, + model: str = "mistral:7b", + image: str = "ollama/ollama:latest", + gpu_devices: bool = False, + ): + self.model = model + self.port = find_free_port() + self.container_name = f"ollama-ray-{self.port}" + self.base_url = f"http://localhost:{self.port}" + + remove_container(self.container_name) + + cmd = [ + "docker", + "run", + "-d", + "--name", + self.container_name, + "-p", + f"{self.port}:11434", + "-v", + "ollama_shared:/root/.ollama", + ] + + if gpu_devices: + cmd.extend( + [ + "--device=/dev/kfd", + "--device=/dev/dri", + "--group-add=video", + ] + ) + + cmd.append(image) + + print(f"[OllamaService] Starting container \ +'{self.container_name}' on port {self.port}...") + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"Docker failed: {result.stderr}") + + print("[OllamaService] Waiting for Ollama to be ready...") + if not wait_for_service(f"{self.base_url}/api/tags"): + raise RuntimeError(f"Ollama didn't start within \ +timeout on port {self.port}") + + print(f"[OllamaService] Pulling model '{model}'...") + pull_result = subprocess.run( + ["docker", "exec", self.container_name, "ollama", "pull", model], + capture_output=True, + text=True, + ) + if pull_result.returncode != 0: + raise RuntimeError(f"Failed to pull model: {pull_result.stderr}") + + print(f"[OllamaService] Ready! \ +container='{self.container_name}', port={self.port}, model='{model}'") + + async def generate( + self, + messages: list, + max_tokens: int = 1024, + temperature: float = 0.1, + ) -> Any: + """ + Send messages to Ollama and return the response. + This is what RAGHandle calls to get LLM answers. + """ + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post( + f"{self.base_url}/api/chat", + json={ + "model": self.model, + "messages": messages, + "stream": False, + "options": { + "num_predict": max_tokens, + "temperature": temperature, + }, + }, + ) + resp.raise_for_status() + return resp.json()["message"]["content"] + + async def health(self) -> dict: + """Check if this Ollama instance is alive.""" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + await client.get(f"{self.base_url}/api/tags") + return { + "status": "ok", + "port": self.port, + "container": self.container_name, + } + except Exception as e: + return {"status": "error", "error": str(e)} + + def __del__(self): + """Cleanup when Ray destroys this replica.""" + print(f"[OllamaService] Removing container '{self.container_name}'") + remove_container(self.container_name) + + +@serve.deployment(num_replicas=1) +class QdrantService: + """ + Manages a Qdrant Docker container. + + LIFECYCLE: + __init__: starts container (or reuses if already running) + get_url: returns the URL other services should connect to + __del__: leaves the container running (it has data!) + """ + + def __init__( + self, + port: int = 6333, + image: str = "qdrant/qdrant:latest", + storage_volume: str = "qdrant_data", + ): + self.port = port + self.container_name = "qdrant-ray" + self.url = f"http://localhost:{self.port}" + + if self._is_healthy(): + print(f"[QdrantService] Qdrant already running on port {self.port}") + return + + remove_container(self.container_name) + + cmd = [ + "docker", + "run", + "-d", + "--name", + self.container_name, + "-p", + f"{self.port}:6333", + "-v", + f"{storage_volume}:/qdrant/storage", + image, + ] + + print(f"[QdrantService] Starting Qdrant on port {self.port}...") + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"Docker failed: {result.stderr}") + + if not wait_for_service(f"{self.url}/healthz"): + raise RuntimeError( + f"Qdrant didn't start within timeout on port {self.port}" + ) + + print(f"[QdrantService] Ready on port {self.port}") + + def _is_healthy(self) -> bool: + try: + resp = httpx.get(f"{self.url}/healthz", timeout=3) + return resp.status_code == 200 + except Exception: + return False + + async def get_url(self) -> str: + """Return the URL. Called by RAGHandle to know where Qdrant is.""" + return self.url + + async def health(self) -> dict: + try: + async with httpx.AsyncClient(timeout=5.0) as client: + await client.get(f"{self.url}/healthz") + return {"status": "ok", "port": self.port, "url": self.url} + except Exception as e: + return {"status": "error", "error": str(e)} + + def __del__(self): + print(f"[QdrantService] Actor destroyed. \ +Container '{self.container_name}' left running.") diff --git a/src/modules/rag/ingestion.py b/src/modules/rag/ingestion.py index 5c458d1..f4e4dae 100644 --- a/src/modules/rag/ingestion.py +++ b/src/modules/rag/ingestion.py @@ -1,72 +1,454 @@ -# ingestion.py import argparse import os +import re +import sys import uuid +from datetime import datetime +from pathlib import Path +from typing import Any, List +from pypdf import PdfReader from qdrant_client import QdrantClient -from qdrant_client.models import VectorParams, Distance, PointStruct +from qdrant_client.models import ( + Distance, + FieldCondition, + Filter, + MatchValue, + PointStruct, + VectorParams, +) +from semantic_chunker import SemanticChunker from sentence_transformers import SentenceTransformer USER_ID_FILE = os.path.expanduser("~/.huri_user_id") -def get_user_id(provided_id: str = None) -> str: - """Use provided ID, or load from file, or generate new one.""" +def _split_sentences(text: str) -> list[str]: + """Simple sentence splitter.""" + result: List = [] + sentences = re.split(r"(?<=[.!?])\s+", text) + + for s in sentences: + parts = s.split("\n\n") + result.extend(parts) + return [s.strip() for s in result if s.strip()] + + +def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]: + """ + Fallback: fixed-size chunking by sentences. + Used when --chunking=fixed. + """ + chunks: List = [] + current_chunk: List = [] + current_length: int = 0 + sentences = _split_sentences(text) + + for sentence in sentences: + sentence = sentence.strip() + if not sentence: + continue + + sentence_length = len(sentence.split()) + + if current_length + sentence_length > chunk_size and current_chunk: + overlap_words: int = 0 + overlap_sentences: List = [] + chunks.append(" ".join(current_chunk)) + + for s in reversed(current_chunk): + overlap_words += len(s.split()) + overlap_sentences.insert(0, s) + if overlap_words >= overlap: + break + + current_chunk = overlap_sentences + current_length = overlap_words + + current_chunk.append(sentence) + current_length += sentence_length + + if current_chunk: + chunks.append(" ".join(current_chunk)) + + return chunks + + +def extract_text_from_pdf(pdf_path: str) -> str: + """Extract text from a PDF file.""" + try: + reader = PdfReader(pdf_path) + text = "" + for page in reader.pages: + text += page.extract_text() + "\n" + return text.strip() + except ImportError: + pass + + print("ERROR: Install a PDF library: pip install pymupdf OR pip install pypdf") + sys.exit(1) + + +def get_user_id(provided_id: str | None = None) -> str: if provided_id: return provided_id if os.path.exists(USER_ID_FILE): with open(USER_ID_FILE) as f: - return f.read().strip() + uid = f.read().strip() + if uid: + return uid new_id = str(uuid.uuid4()) with open(USER_ID_FILE, "w") as f: f.write(new_id) + print(f"Generated new user_id: {new_id}") return new_id +def ensure_collection(client: QdrantClient, collection: str, vector_size: int): + collections = [c.name for c in client.get_collections().collections] + if collection not in collections: + client.create_collection( + collection_name=collection, + vectors_config=VectorParams(size=vector_size, distance=Distance.COSINE), + ) + print(f"Created collection: {collection}") + + +def ingest_chunks( + client: QdrantClient, + model: SentenceTransformer, + collection: str, + chunks: list[str], + _user_id: str, + source: str, + doc_type: str = "document", +): + """Embed chunks and upsert into Qdrant.""" + points = [] + timestamp = datetime.now().isoformat() + + for i, chunk in enumerate(chunks): + vector = model.encode(chunk, normalize_embeddings=True).tolist() + points.append( + PointStruct( + id=str(uuid.uuid4()), + vector=vector, + payload={ + "text": chunk, + "_user_id": _user_id, + "source": source, + "type": doc_type, + "chunk_index": i, + "timestamp": timestamp, + }, + ) + ) + + if points: + # Upsert in batches of 100 + batch_size = 100 + for i in range(0, len(points), batch_size): + batch = points[i : i + batch_size] + client.upsert(collection_name=collection, points=batch) + + return len(points) + + +def chunk_strat(text: str, args, model: SentenceTransformer) -> list[str] | Any: + """Pick the right chunking strategy based on args.""" + if args.chunking == "semantic": + chunker = SemanticChunker( + model=model, + strategy=args.semantic_strategy, + ) + return chunker.chunk(text) + else: + return chunk_text(text, chunk_size=args.chunk_size, overlap=args.overlap) + + +def cmd_pdf(args, client, model, _user_id): + """Ingest PDF files.""" + files: List[Path] = [] + for path in args.files: + p = Path(path) + if p.is_dir(): + files.extend(p.glob("**/*.pdf")) + elif p.suffix.lower() == ".pdf": + files.append(p) + else: + print(f"Skipping non-PDF: {path}") + + if not files: + print("No PDF files found.") + return + + sample = model.encode("test", normalize_embeddings=True) + ensure_collection(client, args.collection, len(sample)) + + total = 0 + for pdf_path in files: + print(f"\nProcessing: {pdf_path}") + text = extract_text_from_pdf(str(pdf_path)) + + if not text.strip(): + print(f" WARNING: No text extracted from {pdf_path}") + continue + + chunks = chunk_strat(text, args, model) + count = ingest_chunks( + client, + model, + args.collection, + chunks, + _user_id, + source=pdf_path.name, + doc_type="pdf", + ) + print(f" -> {count} chunks ingested") + total += count + + print(f"\nDone. Total: {total} chunks from {len(files)} PDF(s)") + + +def cmd_text(args, client, model, _user_id): + """Ingest text files.""" + sample = model.encode("test", normalize_embeddings=True) + ensure_collection(client, args.collection, len(sample)) + + total = 0 + for file_path in args.files: + p = Path(file_path) + if not p.exists(): + print(f"File not found: {file_path}") + continue + + print(f"\nProcessing: {file_path}") + text = p.read_text(encoding="utf-8") + + if not text.strip(): + print(f" WARNING: File is empty: {file_path}") + continue + + chunks = chunk_strat(text, args, model) + count = ingest_chunks( + client, + model, + args.collection, + chunks, + _user_id, + source=p.name, + doc_type="text", + ) + print(f" -> {count} chunks ingested") + total += count + + print(f"\nDone. Total: {total} chunks from {len(args.files)} file(s)") + + +def cmd_write(args, client, model, _user_id): + """Write text interactively and ingest it.""" + title = args.title or f"note_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + + print(f"Write your text below (title: '{title}')") + print("Press Ctrl+D (Linux/Mac) or Ctrl+Z then Enter (Windows) when done.") + print("-" * 40) + + lines = [] + try: + while True: + line = input() + lines.append(line) + except EOFError: + pass + + text = "\n".join(lines).strip() + + if not text: + print("Nothing to ingest.") + return + + print(f"\n{'-' * 40}") + print(f"Received {len(text)} characters") + + sample = model.encode("test", normalize_embeddings=True) + ensure_collection(client, args.collection, len(sample)) + + chunks = chunk_strat(text, args, model) + count = ingest_chunks( + client, + model, + args.collection, + chunks, + _user_id, + source=title, + doc_type="manual", + ) + + print(f"Done. Ingested {count} chunks as '{title}'") + + +def cmd_list(args, client, model, _user_id): + """List what's in the database for this user.""" + + try: + info = client.get_collection(args.collection) + print(f"Collection: {args.collection}") + print(f"Total points: {info.points_count}") + except Exception: + print(f"Collection '{args.collection}' doesn't exist.") + return + + results = client.scroll( + collection_name=args.collection, + scroll_filter=Filter( + must=[ + FieldCondition(key="_user_id", match=MatchValue(value=_user_id)), + ] + ), + limit=100, + with_payload=True, + with_vectors=False, + ) + + points = results[0] + if not points: + print(f"No documents found for user {_user_id}") + return + + sources = {} + for p in points: + source = p.payload.get("source", "unknown") + doc_type = p.payload.get("type", "unknown") + if source not in sources: + sources[source] = {"count": 0, "type": doc_type} + sources[source]["count"] += 1 + + print(f"\nDocuments for user {_user_id}:") + print(f"{'Source':<40} {'Type':<10} {'Chunks':<8}") + print("-" * 60) + for source, info in sorted(sources.items()): + print(f"{source:<40} {info['type']:<10} {info['count']:<8}") + print(f"\nTotal: {len(points)} chunks across {len(sources)} sources") + + +def cmd_delete(args, client, model, _user_id): + """Delete documents by source name.""" + + if not args.source: + print("Specify --source to delete. Use 'list' command to see sources.") + return + + filter_conditions: Any = [ + FieldCondition(key="_user_id", match=MatchValue(value=_user_id)), + FieldCondition(key="source", match=MatchValue(value=args.source)), + ] + + client.delete( + collection_name=args.collection, + points_selector=Filter(must=filter_conditions), + ) + print(f"Deleted all chunks from source '{args.source}' for user {_user_id}") + + def main(): - parser = argparse.ArgumentParser(description="Ingest documents into Qdrant") - parser.add_argument("--user-id", type=str, default=None, help="User ID (reads from ~/.huri_user_id if not provided)") + parser = argparse.ArgumentParser(description="HuRI RAG Ingestion Tool") + parser.add_argument("--user-id", type=str, default=None) parser.add_argument("--collection", type=str, default="documents") parser.add_argument("--qdrant-url", type=str, default="http://localhost:6333") + parser.add_argument("--embedding-model", type=str, default="BAAI/bge-large-en-v1.5") + parser.add_argument( + "--chunk-size", + type=int, + default=500, + help="Target chunk size in words (fixed mode)", + ) + parser.add_argument( + "--overlap", + type=int, + default=50, + help="Overlap between chunks in words (fixed mode)", + ) + parser.add_argument( + "--chunking", + type=str, + default="fixed", + choices=["semantic", "fixed"], + help="Chunking strategy: 'semantic' (default) or 'fixed'", + ) + parser.add_argument( + "--semantic-strategy", + type=str, + default="percentile", + choices=["percentile", "threshold", "stddev"], + help="Semantic chunking strategy (default: percentile)", + ) + + subparsers = parser.add_subparsers(dest="command", required=True) + + p_pdf = subparsers.add_parser("pdf", help="Ingest PDF files") + p_pdf.add_argument("files", nargs="+", help="PDF files or directories") + + p_text = subparsers.add_parser("text", help="Ingest text files (.txt, .md)") + p_text.add_argument("files", nargs="+", help="Text files") + + p_write = subparsers.add_parser("write", help="Write text interactively") + p_write.add_argument("--title", type=str, default=None, help="Title/source name") + + subparsers.add_parser("list", help="List ingested documents") + + p_delete = subparsers.add_parser("delete", help="Delete documents by source") + p_delete.add_argument( + "--source", type=str, required=True, help="Source name to delete" + ) + args = parser.parse_args() - user_id = get_user_id(args.user_id) - print(f"Ingesting for user_id: {user_id}") + _user_id = get_user_id(args._user_id) + print(f"User: {_user_id}") client = QdrantClient(url=args.qdrant_url) - model = SentenceTransformer("BAAI/bge-large-en-v1.5") + model = SentenceTransformer(args.embedding_model) - collections = [c.name for c in client.get_collections().collections] - if args.collection not in collections: - client.create_collection( - collection_name=args.collection, - vectors_config=VectorParams(size=1024, distance=Distance.COSINE), - ) - print(f"Created collection: {args.collection}") + commands = { + "pdf": cmd_pdf, + "text": cmd_text, + "write": cmd_write, + "list": cmd_list, + "delete": cmd_delete, + } + commands[args.command](args, client, model, _user_id) - docs = [ - {"text": "The company budget for 2026 is 2 million euros.", "source": "budget.pdf"}, - {"text": "The project deadline is June 15th 2026.", "source": "planning.pdf"}, - {"text": "The team consists of 5 developers and 2 designers.", "source": "team.pdf"}, - {"text": "The main office is located in Paris, France.", "source": "info.pdf"}, - ] - points = [] - for doc in docs: - vector = model.encode(doc["text"], normalize_embeddings=True).tolist() - points.append(PointStruct( - id=str(uuid.uuid4()), - vector=vector, - payload={ - "text": doc["text"], - "source": doc["source"], - "user_id": user_id, - }, - )) - - client.upsert(collection_name=args.collection, points=points) - print(f"Ingested {len(points)} documents for user {user_id}") +if __name__ == "__main__": + """ + Ingestion tool for HuRI RAG. + Usage: + # Ingest a PDF + python ingestion.py pdf report.pdf -if __name__ == "__main__": - main() \ No newline at end of file + # Ingest multiple PDFs + python ingestion.py pdf doc1.pdf doc2.pdf doc3.pdf + + # Ingest a whole folder of PDFs + # TODO: To verify and to add the support of hole paths + python ingestion.py pdf ./my_documents/ + + # Write text interactively (type, then Ctrl+D to save) + python ingestion.py write --title "My meeting notes" + + # Ingest a text file + python ingestion.py text notes.txt story.md + + # Specify a user ID (otherwise reads from ~/.huri_user_id) + python ingestion.py --user-id "abc-123" pdf report.pdf + + # Use a different collection + python ingestion.py --collection "my_docs" pdf report.pdf + + # Use a different ingestion strategy + python src/modules/rag/ingestion.py \ +--chunking semantic --semantic-strategy threshold pdf "EN.pdf" + + """ + main() diff --git a/src/modules/rag/rag.py b/src/modules/rag/rag.py index ac594d8..ac4e327 100644 --- a/src/modules/rag/rag.py +++ b/src/modules/rag/rag.py @@ -1,32 +1,34 @@ -from typing import Any, Optional from dataclasses import dataclass, field - +from typing import Any, Optional + +import httpx +from qdrant_client import QdrantClient +from qdrant_client.models import FieldCondition, Filter, MatchValue from ray import serve -from src.core.module import ModuleWithHandle, ModuleWithId, handle -from qdrant_client.models import Filter, FieldCondition, MatchValue from sentence_transformers import SentenceTransformer -from qdrant_client import QdrantClient - - -import httpx - - + +from src.core.module import ModuleWithHandle, ModuleWithId + + @dataclass class RAGQuery: """What flows from RAG module to RAGHandle.""" + _user_id: str question: str preferences: dict = field(default_factory=dict) - # preferences can include: language, tone, response_format, max_length, system_prompt, extra_instructions, etc. - - + # preferences can include: language, tone, + # response_format, max_length, system_prompt, extra_instructions, etc. + + @dataclass class RAGResult: """What RAGHandle returns.""" + answer: str sources: list[dict] = field(default_factory=list) - - + + @serve.deployment( num_replicas=2, ray_actor_options={"num_cpus": 1}, @@ -37,13 +39,15 @@ class RAGHandle: Receives a _user_id + question, uses _user_id to find the right collection/data in the vector DB, runs embed -> search -> LLM. """ - + def __init__( self, + ollama_handle=None, + qdrant_handle=None, qdrant_url: str = "http://localhost:6333", default_collection: str = "documents", embedding_model: str = "BAAI/bge-large-en-v1.5", - llm_provider: str = "ollama", # "vllm", "ollama", "api" + llm_provider: str = "ollama", # "vllm", "ollama", "api" llm_url: str = "http://localhost:11434", llm_model: str = "mistral:7b", llm_api_key: str = "", @@ -51,66 +55,75 @@ def __init__( score_threshold: float = 0.5, ): self.embed_model = SentenceTransformer(embedding_model) - self.qdrant = QdrantClient(url=qdrant_url) self.default_collection = default_collection self.top_k = top_k self.score_threshold = score_threshold - + self.llm_provider = llm_provider self.llm_url = llm_url self.llm_model = llm_model self.llm_api_key = llm_api_key - + + self.ollama_handle = ollama_handle + self.qdrant_handle = qdrant_handle + + self._qdrant_url = qdrant_url + self._qdrant: QdrantClient | None = None + + async def _get_qdrant(self): + """Connect to Qdrant on first use. Solves the async-in-init problem.""" + if self._qdrant is None: + if self.qdrant_handle: + self._qdrant_url = await self.qdrant_handle.get_url.remote() + self._qdrant = QdrantClient(url=self._qdrant_url) + print(f"[RAGHandle] Connected to Qdrant at {self._qdrant_url}") + return self._qdrant + def _resolve_user_context(self, _user_id: str) -> tuple[str, dict | None]: """ Given a _user_id, decide which collection to search and which filters to apply. - + Options (pick what fits your data model): A) One collection per user: collection = f"user_{_user_id}" B) Shared collection, filter by _user_id in payload C) Lookup in a DB to find the user's config """ - - # Option A: separate collection per user - # collection = f"user_{_user_id}" - # filters = None - - # Option B: shared collection with _user_id filter (recommended) + collection = self.default_collection filters = {"_user_id": _user_id} - - return collection, filters + return collection, filters - def _embed(self, text) -> list[float]: + def _embed(self, text) -> list[float] | Any: return self.embed_model.encode(str(text), normalize_embeddings=True).tolist() - - def _search( self, + qdrant, query_vector: list[float], collection: str, filters: dict | None = None, ) -> list[dict]: - - qdrant_filter = None + + qdrant_filter: Any = None if filters: - conditions = [ + conditions: Any = [ FieldCondition(key=k, match=MatchValue(value=v)) for k, v in filters.items() ] qdrant_filter = Filter(must=conditions) - results = self.qdrant.query_points( - collection_name=collection, - query=query_vector, - query_filter=qdrant_filter, - limit=self.top_k, - score_threshold=self.score_threshold, - ).points - + try: + results = qdrant.query_points( + collection_name=collection, + query=query_vector, + query_filter=qdrant_filter, + limit=self.top_k, + score_threshold=self.score_threshold, + ).points + except Exception: + results = [] return [ { "text": point.payload.get("text", ""), @@ -120,7 +133,6 @@ def _search( for point in results ] - def _build_prompt( self, question: str, @@ -143,7 +155,7 @@ def _build_prompt( if preferences.get("extra_instructions"): parts.append(preferences["extra_instructions"]) system_prompt = " ".join(parts) - + if not chunks: user_prompt = ( "No relevant context was found.\n\n" @@ -154,74 +166,84 @@ def _build_prompt( context_parts = [] for i, chunk in enumerate(chunks, 1): source = chunk["metadata"].get("source", "unknown") - context_parts.append( - f"[{i}] (source: {source}, score: {chunk['score']:.2f})\n{chunk['text']}" - ) + context_parts.append(f"[{i}] (source: {source}, score: \ +{chunk['score']:.2f})\n{chunk['text']}") context_block = "\n\n".join(context_parts) user_prompt = ( f"Context:\n{context_block}\n\n" f"Question: {question}\n\n" - "Answer based on the context above. Don't speak about the sources, just use them to answer the question." + "Answer based on the context above.\ +Don't speak about the sources, just use them to answer the question." ) - - return system_prompt, user_prompt + return system_prompt, user_prompt async def _llm_generate( self, system_prompt: str, user_prompt: str, preferences: dict, - ) -> str: + ) -> Any: max_tokens = preferences.get("max_length", 1024) messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ] - + + if self.ollama_handle: + return await self.ollama_handle.generate.remote(messages, max_tokens) + if self.llm_provider == "vllm": return await self._call_openai_compatible( f"{self.llm_url}/v1/chat/completions", messages, max_tokens ) elif self.llm_provider == "ollama": return await self._call_ollama(messages, max_tokens) + elif self.llm_provider == "api": return await self._call_openai_compatible( - f"{self.llm_url}/v1/chat/completions", messages, max_tokens, self.llm_api_key + f"{self.llm_url}/v1/chat/completions", + messages, + max_tokens, + self.llm_api_key, ) else: raise ValueError(f"Unknown llm_provider: {self.llm_provider}") - async def _call_openai_compatible( self, url: str, messages: list, max_tokens: int, api_key: str = "" - ) -> str: + ) -> Any: headers = {"Content-Type": "application/json"} if api_key: headers["Authorization"] = f"Bearer {api_key}" async with httpx.AsyncClient(timeout=60.0) as client: - resp = await client.post(url, headers=headers, json={ - "model": self.llm_model, - "messages": messages, - "max_tokens": max_tokens, - "temperature": 0.1, - }) + resp = await client.post( + url, + headers=headers, + json={ + "model": self.llm_model, + "messages": messages, + "max_tokens": max_tokens, + "temperature": 0.1, + }, + ) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"] - - async def _call_ollama(self, messages: list, max_tokens: int) -> str: + async def _call_ollama(self, messages: list, max_tokens: int) -> Any: async with httpx.AsyncClient(timeout=60.0) as client: - resp = await client.post(f"{self.llm_url}/api/chat", json={ - "model": self.llm_model, - "messages": messages, - "stream": False, - "options": {"num_predict": max_tokens, "temperature": 0.1}, - }) + resp = await client.post( + f"{self.llm_url}/api/chat", + json={ + "model": self.llm_model, + "messages": messages, + "stream": False, + "options": {"num_predict": max_tokens, "temperature": 0.1}, + }, + ) resp.raise_for_status() return resp.json()["message"]["content"] - async def process(self, query: RAGQuery) -> RAGResult: """ Main entry point. Called by the RAG module. @@ -229,10 +251,12 @@ async def process(self, query: RAGQuery) -> RAGResult: """ print(f"[RAG] Question: {query.question}") - collection, filters = self._resolve_user_context(query._user_id) - query_vector = self._embed(query.question) - chunks = self._search(query_vector, collection, filters) + qdrant = await self._get_qdrant() + + collection, filters = self._resolve_user_context(query._user_id) + query_vector = self._embed(query.question) + chunks = self._search(qdrant, query_vector, collection, filters) print(f"[RAG] Found {len(chunks)} chunks") for c in chunks: @@ -241,7 +265,7 @@ async def process(self, query: RAGQuery) -> RAGResult: system_prompt, user_prompt = self._build_prompt( query.question, chunks, query.preferences ) - print(f"[RAG] System prompt: {system_prompt[:200]}...") + print(f"[RAG] System prompt: {system_prompt[:200]}...") answer = await self._llm_generate(system_prompt, user_prompt, query.preferences) print(f"[RAG] Answer: {answer}") @@ -252,8 +276,8 @@ async def process(self, query: RAGQuery) -> RAGResult: for c in chunks ], ) - - + + class RAG(ModuleWithHandle, ModuleWithId): _handle_cls = RAGHandle input_type = "question" @@ -284,17 +308,18 @@ async def process(self, data) -> Optional[Any]: Called when a "question" event arrives through the event bus. Packages _user_id + question, sends to the stateless RAGHandle. """ - question_text = data.text if hasattr(data, 'text') else str(data) + question_text = data.text if hasattr(data, "text") else str(data) query = RAGQuery( _user_id=self._user_id if self._user_id else "anonymous", question=question_text, preferences=self.preferences, ) - - result: RAGResult = await self._handle.process.remote(query) - return result + result: RAGResult | Any = None + if self._handle is not None: + result = await self._handle.process.remote(query) + return result def update_preferences(self, new_preferences: dict): """Client can update preferences mid-session via the event bus.""" diff --git a/src/modules/rag/semantic_chunker.py b/src/modules/rag/semantic_chunker.py new file mode 100644 index 0000000..55e7e85 --- /dev/null +++ b/src/modules/rag/semantic_chunker.py @@ -0,0 +1,251 @@ +""" +Semantic Chunking for RAG. + +Three strategies: + 1. percentile - cut where similarity is below the Nth percentile (default) + 2. threshold - cut where similarity drops below a fixed value + 3. stddev - cut where similarity is more than N std devs below the mean + +Usage: + from semantic_chunker import SemanticChunker + + chunker = SemanticChunker(embedding_model) + chunks = chunker.chunk(text) +""" + +import re +from dataclasses import dataclass, field + +import numpy as np +from sentence_transformers import SentenceTransformer + + +@dataclass +class Chunk: + text: str + sentences: list[str] = field(default_factory=list) + start_idx: int = 0 + end_idx: int = 0 + + +class SemanticChunker: + def __init__( + self, + model: SentenceTransformer, + strategy: str = "percentile", # "percentile", "threshold", "stddev" + percentile_cutoff: float = 25, # for percentile strategy + threshold_cutoff: float = 0.5, # for threshold strategy + stddev_cutoff: float = 1.0, # for stddev strategy (N std devs below mean) + min_chunk_size: int = 2, # minimum sentences per chunk + max_chunk_size: int = 50, # maximum sentences per chunk + buffer_size: int = 1, # sentences to look around for context + ): + self.model = model + self.strategy = strategy + self.percentile_cutoff = percentile_cutoff + self.threshold_cutoff = threshold_cutoff + self.stddev_cutoff = stddev_cutoff + self.min_chunk_size = min_chunk_size + self.max_chunk_size = max_chunk_size + self.buffer_size = buffer_size + + def chunk(self, text: str) -> list[str]: + """Main entry point. Returns list of chunk texts.""" + sentences = self._split_sentences(text) + + if len(sentences) <= self.min_chunk_size: + return [text.strip()] if text.strip() else [] + + combined = self._combine_with_buffer(sentences) + embeddings = self.model.encode(combined, normalize_embeddings=True) + similarities = self._calculate_similarities(embeddings.numpy()) + breakpoints = self._find_breakpoints(similarities) + chunks = self._create_chunks(sentences, breakpoints) + + return chunks + + def chunk_detailed(self, text: str) -> list[Chunk]: + """Returns detailed Chunk objects with metadata.""" + sentences = self._split_sentences(text) + + if len(sentences) <= self.min_chunk_size: + return [ + Chunk( + text=text.strip(), + sentences=sentences, + start_idx=0, + end_idx=len(sentences), + ) + ] + + combined = self._combine_with_buffer(sentences) + embeddings = self.model.encode(combined, normalize_embeddings=True) + similarities = self._calculate_similarities(embeddings.numpy()) + breakpoints = self._find_breakpoints(similarities) + + chunks = [] + start = 0 + for bp in breakpoints: + end = bp + 1 + chunk_sentences = sentences[start:end] + chunks.append( + Chunk( + text=" ".join(chunk_sentences), + sentences=chunk_sentences, + start_idx=start, + end_idx=end, + ) + ) + start = end + + if start < len(sentences): + chunk_sentences = sentences[start:] + chunks.append( + Chunk( + text=" ".join(chunk_sentences), + sentences=chunk_sentences, + start_idx=start, + end_idx=len(sentences), + ) + ) + + return chunks + + def _split_sentences(self, text: str) -> list[str]: + """Split text into sentences, respecting paragraph boundaries.""" + paragraphs = text.split("\n\n") + sentences = [] + for para in paragraphs: + para = para.strip() + if not para: + continue + parts = re.split(r"(?<=[.!?])\s+", para) + for part in parts: + part = part.strip() + if part: + sentences.append(part) + return sentences + + def _combine_with_buffer(self, sentences: list[str]) -> list[str]: + """ + Combine each sentence with its neighbors for richer embeddings. + Sentence at index i gets combined with sentences [i-buffer, i+buffer]. + This gives the embedding model more context to understand each sentence. + """ + combined = [] + for i in range(len(sentences)): + start = max(0, i - self.buffer_size) + end = min(len(sentences), i + self.buffer_size + 1) + window = " ".join(sentences[start:end]) + combined.append(window) + return combined + + def _calculate_similarities(self, embeddings: np.ndarray) -> list[float]: + """Calculate cosine similarity between consecutive sentence embeddings.""" + similarities = [] + for i in range(len(embeddings) - 1): + sim = np.dot(embeddings[i], embeddings[i + 1]) + similarities.append(float(sim)) + return similarities + + def _find_breakpoints(self, similarities: list[float]) -> list[int]: + """Find where to split based on the chosen strategy.""" + if not similarities: + return [] + + sims = np.array(similarities) + + if self.strategy == "percentile": + cutoff = np.percentile(sims, self.percentile_cutoff) + candidate_indices = [i for i, s in enumerate(similarities) if s < cutoff] + + elif self.strategy == "threshold": + candidate_indices = [ + i for i, s in enumerate(similarities) if s < self.threshold_cutoff + ] + + elif self.strategy == "stddev": + mean = np.mean(sims) + std = np.std(sims) + cutoff = mean - (self.stddev_cutoff * std) + candidate_indices = [i for i, s in enumerate(similarities) if s < cutoff] + + else: + raise ValueError(f"Unknown strategy: {self.strategy}") + + breakpoints = self._enforce_chunk_sizes( + candidate_indices, len(similarities) + 1 + ) + + return breakpoints + + def _enforce_chunk_sizes( + self, candidates: list[int], num_sentences: int + ) -> list[int]: + """Ensure chunks respect min and max size constraints.""" + if not candidates: + breakpoints = [] + pos = self.max_chunk_size - 1 + while pos < num_sentences - 1: + breakpoints.append(pos) + pos += self.max_chunk_size + return breakpoints + + breakpoints = [] + last_break = -1 + + for candidate in sorted(candidates): + chunk_size = candidate - last_break + + if chunk_size < self.min_chunk_size: + continue + + if chunk_size > self.max_chunk_size: + pos = last_break + self.max_chunk_size + while pos < candidate: + breakpoints.append(pos) + last_break = pos + pos += self.max_chunk_size + + breakpoints.append(candidate) + last_break = candidate + + remaining = num_sentences - 1 - last_break + if remaining > self.max_chunk_size: + pos = last_break + self.max_chunk_size + while pos < num_sentences - 1: + breakpoints.append(pos) + pos += self.max_chunk_size + + return breakpoints + + def _create_chunks(self, sentences: list[str], breakpoints: list[int]) -> list[str]: + """Group sentences into chunks based on breakpoints.""" + chunks = [] + start = 0 + + for bp in breakpoints: + end = bp + 1 + chunk_text = " ".join(sentences[start:end]).strip() + if chunk_text: + chunks.append(chunk_text) + start = end + + if start < len(sentences): + chunk_text = " ".join(sentences[start:]).strip() + if chunk_text: + chunks.append(chunk_text) + + return chunks + + +def create_chunker( + model: SentenceTransformer | None = None, + model_name: str = "BAAI/bge-large-en-v1.5", + strategy: str = "percentile", + **kwargs, +) -> SemanticChunker: + """Create a chunker with defaults.""" + if model is None: + model = SentenceTransformer(model_name) + return SemanticChunker(model=model, strategy=strategy, **kwargs) diff --git a/src/modules/reasoning/__init__.py b/src/modules/reasoning/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/modules/reasoning/embedding.py b/src/modules/reasoning/embedding.py deleted file mode 100644 index b6139d9..0000000 --- a/src/modules/reasoning/embedding.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import Any, Optional - -import numpy as np -from ray import serve -from ray.serve import handle - -from src.core.module import ModuleWithHandle - - -@serve.deployment -class EMBHandle: - def __init__( - self, - model_name: str = "name", - ): - super().__init__() - - self.model = model_name # TODO MVR load embedding model - - async def embbed(self, data_to_embed: str) -> Optional[Any]: - result = self.model + data_to_embed - - return result - - -class EMB(ModuleWithHandle): - _handle_cls = EMBHandle - - input_type = "toembed" - output_type = "embedded" - - def __init__(self, _handle: handle.DeploymentHandle[EMBHandle]): - super().__init__(_handle) - - self.database = "" - - async def process(self, data_to_embed: np.ndarray) -> Optional[Any]: - embedded = await self._handle.embbed.remote(data_to_embed) - - # TODO write embedding - return embedded From e6cd1342781c921473afc2839b72dcc4aaa41b72 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 24 May 2026 15:43:33 +0200 Subject: [PATCH 53/65] evol(client): moved save/load user id in new client class --- requirements.txt | 2 ++ src/client.py | 15 --------------- src/core/client.py | 23 ++++++++++++++++++++++- src/core/dataclasses/config.py | 4 +++- src/core/huri.py | 10 +++++----- src/modules/factory.py | 10 ++++------ 6 files changed, 36 insertions(+), 28 deletions(-) diff --git a/requirements.txt b/requirements.txt index dec2af0..9f337e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,8 @@ webrtcvad faster-whisper qdrant-client sentence-transformers +pypdf +semantic_chunker # client diff --git a/src/client.py b/src/client.py index 1a95dad..99e9ada 100644 --- a/src/client.py +++ b/src/client.py @@ -1,6 +1,5 @@ import argparse import asyncio -import os from typing import Dict from omegaconf import OmegaConf @@ -8,20 +7,6 @@ from src.core.client import Client from src.core.dataclasses.config import ClientConfig -USER_ID_FILE = os.path.expanduser("~/.huri_user_id") - - -def load_user_id() -> str | None: - if os.path.exists(USER_ID_FILE): - with open(USER_ID_FILE) as f: - return f.read().strip() - return None - - -def save_user_id(_user_id: str): - with open(USER_ID_FILE, "w") as f: - f.write(_user_id) - def load_client_config(path: str) -> ClientConfig: with open(path) as f: diff --git a/src/core/client.py b/src/core/client.py index 656ea6f..28dc36e 100644 --- a/src/core/client.py +++ b/src/core/client.py @@ -1,7 +1,8 @@ import asyncio import json +import os from dataclasses import asdict -from typing import Dict, List, Type +from typing import Dict, List, Optional, Type import websockets @@ -16,11 +17,23 @@ class Client: def __init__( self, config: ClientConfig, + user_id_file: str = os.path.expanduser("~/.huri_user_id"), senders_dict: Dict[str, Type[ClientSender]] = get_senders(), ): self.config = config + self.user_id_file = user_id_file self.senders_dict = senders_dict + def _load_user_id(self) -> Optional[str]: + if os.path.exists(self.user_id_file): + with open(self.user_id_file) as f: + return f.read().strip() + return None + + def _save_user_id(self, _user_id: str): + with open(self.user_id_file, "w") as f: + f.write(_user_id) + async def _receive_loop(self, ws: websockets.ClientConnection): while True: text = await ws.recv() @@ -31,6 +44,8 @@ async def run(self): async with websockets.connect(self.config.huri_url) as ws: print("Connected to server") + self.config.user_id = self._load_user_id() + senders: List[ClientSender] = [ self.senders_dict[config.name](ws=ws, **config.args) for config in self.config.senders.values() @@ -38,6 +53,12 @@ async def run(self): await ws.send(json.dumps(asdict(self.config))) + init_msg = json.loads(await ws.recv()) + if init_msg.get("type") == "session_init": + user_id = init_msg["user_id"] + self._save_user_id(user_id) + print(f"Session started with _user_id: {user_id}") + await asyncio.gather( *(sender.input_loop() for sender in senders), self._receive_loop(ws), diff --git a/src/core/dataclasses/config.py b/src/core/dataclasses/config.py index cc87176..aea111f 100644 --- a/src/core/dataclasses/config.py +++ b/src/core/dataclasses/config.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, List, Mapping +from typing import Any, Dict, List, Mapping, Optional @dataclass @@ -30,6 +30,7 @@ def from_dict(self, raw: dict) -> "ClientSenderConfig": @dataclass class ClientConfig: + user_id: Optional[str] huri_url: str topic_list: List[str] senders: Dict[str, ClientSenderConfig] @@ -46,6 +47,7 @@ def from_dict(cls, raw: Dict) -> "ClientConfig": for module_id, mod_raw in raw.get("modules", {}).items() } return cls( + user_id=None, huri_url=raw["huri_url"], topic_list=raw["topic_list"], senders=senders, diff --git a/src/core/huri.py b/src/core/huri.py index 5743659..e749954 100644 --- a/src/core/huri.py +++ b/src/core/huri.py @@ -44,21 +44,21 @@ async def run_session(self, ws: WebSocket): client_config_raw: Dict = await ws.receive_json() client_config = ClientConfig.from_dict(client_config_raw) - _user_id = client_config_raw.get("_user_id") or str(uuid.uuid4()) + user_id = client_config_raw.get("user_id") or str(uuid.uuid4()) senders: List[Module] = [ Sender(ws, topic) for topic in client_config.topic_list ] modules: List[Module] = ( - self.module_factory.create_from_config(_user_id, client_config.modules) + self.module_factory.create_from_config(user_id, client_config.modules) + senders ) - await ws.send_json({"type": "session_init", "_user_id": _user_id}) + await ws.send_json({"type": "session_init", "user_id": user_id}) session_id = str(uuid.uuid4()) self.clients[session_id] = Session(modules) - print(f"Client registered with _user_id={_user_id}, config: {client_config}") + print(f"Client registered with _user_id={user_id}, config: {client_config}") async def receive_loop(session: Session, ws: WebSocket): try: @@ -85,7 +85,7 @@ async def receive_loop(session: Session, ws: WebSocket): await session.publish(topic, data) except (WebSocketDisconnect, RuntimeError): - print(f"Client {_user_id} disconnected") + print(f"Client {user_id} disconnected") await receive_loop(self.clients[session_id], ws) del self.clients[session_id] diff --git a/src/modules/factory.py b/src/modules/factory.py index 7d8d783..8fcb5cc 100644 --- a/src/modules/factory.py +++ b/src/modules/factory.py @@ -57,7 +57,7 @@ def register(self, name: str, module_cls: Type[Module]) -> None: self._registry[name] = module_cls def create( - self, _user_id: str, name: str, args: Mapping[str, Any] | None = None + self, user_id: str, name: str, args: Mapping[str, Any] | None = None ) -> Module: if name not in self._registry: @@ -76,18 +76,16 @@ def create( kwargs["_handle"] = self._handles[name] if issubclass(module_cls, ModuleWithId): - kwargs["_user_id"] = _user_id + kwargs["_user_id"] = user_id return module_cls(**kwargs) def create_from_config( - self, _user_id: str, module_configs: Dict[str, ModuleConfig] + self, user_id: str, module_configs: Dict[str, ModuleConfig] ) -> List[Module]: modules: List[Module] = [] for module_config in module_configs.values(): - modules.append( - self.create(_user_id, module_config.name, module_config.args) - ) + modules.append(self.create(user_id, module_config.name, module_config.args)) if modules == []: raise Exception From 734ec67d33f9737cb08f934d605b73e28469d2ea Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 24 May 2026 17:34:47 +0200 Subject: [PATCH 54/65] fix(events): fix class comparision --- src/modules/factory.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/factory.py b/src/modules/factory.py index 8fcb5cc..728aba3 100644 --- a/src/modules/factory.py +++ b/src/modules/factory.py @@ -27,18 +27,18 @@ def create(self, topic: str, data: Mapping[str, Any] | bytes) -> EventData | byt event_cls = self._registry[topic] if isinstance(data, bytes): - if isinstance(event_cls, bytes): + if issubclass(event_cls, bytes): return data else: raise RuntimeError(f"mismatched event data type: \ -{event_cls} is not bytes but should be.") +{event_cls} is not type bytes but should be.") else: - if isinstance(event_cls, EventData): + if issubclass(event_cls, EventData): return event_cls(**data) else: raise RuntimeError(f"mismatched event data type: \ -{event_cls} is not EventData but should be.") +{event_cls} is not derived from EventData but should be.") class ModuleFactory: From 54b1fcde9b9464be442de918fa7922ccc7592234 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 24 May 2026 17:36:09 +0200 Subject: [PATCH 55/65] evol(client): cleaner exit --- src/client.py | 5 ++++- src/core/client.py | 16 +++++++++++----- src/core/client_senders.py | 18 +++++++++++++----- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/client.py b/src/client.py index 99e9ada..466cc05 100644 --- a/src/client.py +++ b/src/client.py @@ -34,4 +34,7 @@ async def launch_client(): if __name__ == "__main__": - asyncio.run(launch_client()) + try: + asyncio.run(launch_client()) + except KeyboardInterrupt: + pass diff --git a/src/core/client.py b/src/core/client.py index 28dc36e..085a0b8 100644 --- a/src/core/client.py +++ b/src/core/client.py @@ -35,10 +35,14 @@ def _save_user_id(self, _user_id: str): f.write(_user_id) async def _receive_loop(self, ws: websockets.ClientConnection): - while True: - text = await ws.recv() - print("<<", text) - await asyncio.sleep(0.1) + try: + while True: + text = await ws.recv() + print("<<", text) + await asyncio.sleep(0.1) + + except (asyncio.CancelledError, websockets.ConnectionClosedOK): + pass async def run(self): async with websockets.connect(self.config.huri_url) as ws: @@ -59,7 +63,9 @@ async def run(self): self._save_user_id(user_id) print(f"Session started with _user_id: {user_id}") + receive_task = asyncio.create_task(self._receive_loop(ws)) await asyncio.gather( *(sender.input_loop() for sender in senders), - self._receive_loop(ws), ) + + receive_task.cancel() diff --git a/src/core/client_senders.py b/src/core/client_senders.py index 163269f..03301a6 100644 --- a/src/core/client_senders.py +++ b/src/core/client_senders.py @@ -82,12 +82,20 @@ def __init__(self, **kwargs): super().__init__(**kwargs) async def input_loop(self): + print("'\\exit' or CTRL+D/C to exit.") session: PromptSession = PromptSession() - while True: - with patch_stdout(): - text = await session.prompt_async(">> ") - - await self.send(self.output_type, Sentence(text)) + try: + while True: + with patch_stdout(): + text = await session.prompt_async(">> ") + if text == "\\exit": + return + await self.send(self.output_type, Sentence(text)) + + except (EOFError, KeyboardInterrupt): + pass + finally: + print("TextSender Exited...") def get_senders() -> Dict[str, Type[ClientSender]]: From 2576631c3697b4aba26c6ed38d130f4ca1a278e1 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 24 May 2026 17:36:40 +0200 Subject: [PATCH 56/65] evol(huri): explicit client runtime error --- src/core/huri.py | 6 +++++- src/modules/utils/sender.py | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/huri.py b/src/core/huri.py index e749954..42448cb 100644 --- a/src/core/huri.py +++ b/src/core/huri.py @@ -84,7 +84,11 @@ async def receive_loop(session: Session, ws: WebSocket): await session.publish(topic, data) - except (WebSocketDisconnect, RuntimeError): + except RuntimeError as e: + print(f"[ERROR] Client {user_id}:", e) + except WebSocketDisconnect: + pass + finally: print(f"Client {user_id} disconnected") await receive_loop(self.clients[session_id], ws) diff --git a/src/modules/utils/sender.py b/src/modules/utils/sender.py index 155303b..f09b0ba 100644 --- a/src/modules/utils/sender.py +++ b/src/modules/utils/sender.py @@ -22,7 +22,6 @@ def __init__(self, ws: WebSocket, type: str): self.input_type = type async def process(self, data: EventData | bytes): - print(data) if isinstance(data, bytes): await self.ws.send_bytes(data) elif isinstance(data, EventData): From 0a0bcf6c9b350619a05e7b1213f83bc246f2fc68 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 24 May 2026 17:37:46 +0200 Subject: [PATCH 57/65] evol(rag): implemented event for rag --- config/client_aux2.yaml | 22 ++++++++++++++-------- config/client_auxio.yaml | 9 ++------- config/client_text.yaml | 9 ++++++--- src/core/module.py | 2 +- src/modules/events.py | 2 ++ src/modules/rag/events.py | 11 +++++++++++ src/modules/rag/rag.py | 25 ++++++++++--------------- 7 files changed, 46 insertions(+), 34 deletions(-) create mode 100644 src/modules/rag/events.py diff --git a/config/client_aux2.yaml b/config/client_aux2.yaml index 09595b4..7d7b601 100644 --- a/config/client_aux2.yaml +++ b/config/client_aux2.yaml @@ -1,20 +1,26 @@ huri_url: ws://localhost:8000/session -topic_list: ["transcript", "question", "rag_response"] -sample_rate: 16000 -frame_duration: 0.030 +topic_list: [transcript, question, rag_response] + +senders: + audio: + name: audio + args: + sample_rate: 16000 + frame_duration: 0.030 + modules: mic: name: mic args: vad_agressiveness: 3 silence_duration: 1.5 - block_duration: ${frame_duration} + block_duration: ${senders.audio.args.frame_duration} stt: name: stt args: - language: "en" - block_duration: ${frame_duration} + language: en + block_duration: ${senders.audio.args.frame_duration} logging: INFO tag: name: tag @@ -22,5 +28,5 @@ modules: rag: name: rag args: - language: "en" - tone: "formal" + language: en + tone: formal diff --git a/config/client_auxio.yaml b/config/client_auxio.yaml index 18bfb2e..8fa2a91 100644 --- a/config/client_auxio.yaml +++ b/config/client_auxio.yaml @@ -3,11 +3,6 @@ huri_url: ws://localhost:8000/session topic_list: [question] senders: - audio: - name: audio - args: - sample_rate: 16000 - frame_duration: 0.030 text: name: text @@ -17,13 +12,13 @@ modules: args: vad_agressiveness: 3 silence_duration: 1.5 - block_duration: ${inputs.audio.args.frame_duration} + block_duration: ${senders.audio.args.frame_duration} logging: INFO stt: name: stt args: language: en - block_duration: ${inputs.audio.args.frame_duration} + block_duration: ${senders.audio.args.frame_duration} logging: INFO tag: name: tag diff --git a/config/client_text.yaml b/config/client_text.yaml index 319e871..8ddcaab 100644 --- a/config/client_text.yaml +++ b/config/client_text.yaml @@ -1,12 +1,15 @@ huri_url: ws://localhost:8000/session -topic_list: [question] +topic_list: [question, rag_response] senders: text: name: text modules: - tag: - name: tag + rag: + name: rag + args: + language: en + tone: formal logging: INFO diff --git a/src/core/module.py b/src/core/module.py index a882049..0a571a8 100644 --- a/src/core/module.py +++ b/src/core/module.py @@ -14,7 +14,7 @@ async def process(self, _) -> Optional[Any]: class ModuleWithHandle(Module): _handle_cls: Type[Any] - def __init__(self, _handle: handle.DeploymentHandle | None = None, **kwargs): + def __init__(self, _handle: handle.DeploymentHandle, **kwargs): super().__init__(**kwargs) self._handle = _handle diff --git a/src/modules/events.py b/src/modules/events.py index c974b97..43f6c71 100644 --- a/src/modules/events.py +++ b/src/modules/events.py @@ -1,6 +1,7 @@ from typing import Dict, Type from src.core.events import EventData +from src.modules.rag.events import RAGResult from src.modules.speech_to_text.events import Sentence, Transcript, Voice @@ -10,4 +11,5 @@ def get_events() -> Dict[str, Type[EventData | bytes]]: "voice": Voice, "transcript": Transcript, "question": Sentence, + "rag_response": RAGResult, } diff --git a/src/modules/rag/events.py b/src/modules/rag/events.py new file mode 100644 index 0000000..5d237d2 --- /dev/null +++ b/src/modules/rag/events.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass, field + +from src.core.events import EventData + + +@dataclass +class RAGResult(EventData): + """What RAGHandle returns.""" + + answer: str + sources: list[dict] = field(default_factory=list) diff --git a/src/modules/rag/rag.py b/src/modules/rag/rag.py index ac4e327..6b9744d 100644 --- a/src/modules/rag/rag.py +++ b/src/modules/rag/rag.py @@ -5,9 +5,13 @@ from qdrant_client import QdrantClient from qdrant_client.models import FieldCondition, Filter, MatchValue from ray import serve +from ray.serve import handle from sentence_transformers import SentenceTransformer from src.core.module import ModuleWithHandle, ModuleWithId +from src.modules.speech_to_text.events import Sentence + +from .events import RAGResult @dataclass @@ -21,14 +25,6 @@ class RAGQuery: # response_format, max_length, system_prompt, extra_instructions, etc. -@dataclass -class RAGResult: - """What RAGHandle returns.""" - - answer: str - sources: list[dict] = field(default_factory=list) - - @serve.deployment( num_replicas=2, ray_actor_options={"num_cpus": 1}, @@ -285,8 +281,8 @@ class RAG(ModuleWithHandle, ModuleWithId): def __init__( self, - _handle=None, - _user_id="", + _handle: handle.DeploymentHandle[RAGHandle], + _user_id: str, language="en", tone="formal", response_format="paragraph", @@ -295,6 +291,7 @@ def __init__( **kwargs, ): super().__init__(_handle=_handle, _user_id=_user_id, **kwargs) + self.preferences = { "language": language, "tone": tone, @@ -303,12 +300,12 @@ def __init__( "extra_instructions": extra_instructions, } - async def process(self, data) -> Optional[Any]: + async def process(self, data: Sentence) -> Optional[RAGResult]: """ Called when a "question" event arrives through the event bus. Packages _user_id + question, sends to the stateless RAGHandle. """ - question_text = data.text if hasattr(data, "text") else str(data) + question_text = data.text query = RAGQuery( _user_id=self._user_id if self._user_id else "anonymous", @@ -316,9 +313,7 @@ async def process(self, data) -> Optional[Any]: preferences=self.preferences, ) - result: RAGResult | Any = None - if self._handle is not None: - result = await self._handle.process.remote(query) + result: RAGResult = await self._handle.process.remote(query) return result def update_preferences(self, new_preferences: dict): From 230f9ea5e2f6a45f09c51e87685d37efc5d9cba2 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 25 May 2026 10:49:40 +0200 Subject: [PATCH 58/65] feat(doxygen): added doxygen documentation --- .gitignore | 3 +- Doxyfile | 2932 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 2934 insertions(+), 1 deletion(-) create mode 100644 Doxyfile diff --git a/.gitignore b/.gitignore index 99ba63f..7892d64 100644 --- a/.gitignore +++ b/.gitignore @@ -177,4 +177,5 @@ cython_debug/ .pypirc # Others -.trash \ No newline at end of file +.trash +docs \ No newline at end of file diff --git a/Doxyfile b/Doxyfile new file mode 100644 index 0000000..f5dbb0e --- /dev/null +++ b/Doxyfile @@ -0,0 +1,2932 @@ +# Doxyfile 1.13.2 + +# This file describes the settings to be used by the documentation system +# Doxygen (www.doxygen.org) for a project. +# +# All text after a double hash (##) is considered a comment and is placed in +# front of the TAG it is preceding. +# +# All text after a single hash (#) is considered a comment and will be ignored. +# The format is: +# TAG = value [value, ...] +# For lists, items can also be appended using: +# TAG += value [value, ...] +# Values that contain spaces should be placed between quotes (\" \"). +# +# Note: +# +# Use Doxygen to compare the used configuration file with the template +# configuration file: +# doxygen -x [configFile] +# Use Doxygen to compare the used configuration file with the template +# configuration file without replacing the environment variables or CMake type +# replacement variables: +# doxygen -x_noenv [configFile] + +#--------------------------------------------------------------------------- +# Project related configuration options +#--------------------------------------------------------------------------- + +# This tag specifies the encoding used for all characters in the configuration +# file that follow. The default is UTF-8 which is also the encoding used for all +# text before the first occurrence of this tag. Doxygen uses libiconv (or the +# iconv built into libc) for the transcoding. See +# https://www.gnu.org/software/libiconv/ for the list of possible encodings. +# The default value is: UTF-8. + +DOXYFILE_ENCODING = UTF-8 + +# The PROJECT_NAME tag is a single word (or a sequence of words surrounded by +# double-quotes, unless you are using Doxywizard) that should identify the +# project for which the documentation is generated. This name is used in the +# title of most generated pages and in a few other places. +# The default value is: My Project. + +PROJECT_NAME = "HuRI" + +# The PROJECT_NUMBER tag can be used to enter a project or revision number. This +# could be handy for archiving the generated documentation or if some version +# control system is used. + +PROJECT_NUMBER = + +# Using the PROJECT_BRIEF tag one can provide an optional one line description +# for a project that appears at the top of each page and should give viewers a +# quick idea about the purpose of the project. Keep the description short. + +PROJECT_BRIEF = HuRI is an open-source research project focused on conversational AI for humanoid robots and virtual avatars. +HuRI provides a modular architecture that allows developers to design, implement, and run AI models within customizable conversational pipelines defined by the user. The platform supports the integration of multiple AI modules, including: +Speech-to-Text (STT) and Text-to-Speech (TTS), Retrieval-Augmented Generation (RAG), Emotional analysis (EMO), Motion and gesture generation (MOV) + +# With the PROJECT_LOGO tag one can specify a logo or an icon that is included +# in the documentation. The maximum height of the logo should not exceed 55 +# pixels and the maximum width should not exceed 200 pixels. Doxygen will copy +# the logo to the output directory. + +PROJECT_LOGO = + +# With the PROJECT_ICON tag one can specify an icon that is included in the tabs +# when the HTML document is shown. Doxygen will copy the logo to the output +# directory. + +PROJECT_ICON = + +# The OUTPUT_DIRECTORY tag is used to specify the (relative or absolute) path +# into which the generated documentation will be written. If a relative path is +# entered, it will be relative to the location where Doxygen was started. If +# left blank the current directory will be used. + +OUTPUT_DIRECTORY = docs + +# If the CREATE_SUBDIRS tag is set to YES then Doxygen will create up to 4096 +# sub-directories (in 2 levels) under the output directory of each output format +# and will distribute the generated files over these directories. Enabling this +# option can be useful when feeding Doxygen a huge amount of source files, where +# putting all generated files in the same directory would otherwise cause +# performance problems for the file system. Adapt CREATE_SUBDIRS_LEVEL to +# control the number of sub-directories. +# The default value is: NO. + +CREATE_SUBDIRS = NO + +# Controls the number of sub-directories that will be created when +# CREATE_SUBDIRS tag is set to YES. Level 0 represents 16 directories, and every +# level increment doubles the number of directories, resulting in 4096 +# directories at level 8 which is the default and also the maximum value. The +# sub-directories are organized in 2 levels, the first level always has a fixed +# number of 16 directories. +# Minimum value: 0, maximum value: 8, default value: 8. +# This tag requires that the tag CREATE_SUBDIRS is set to YES. + +CREATE_SUBDIRS_LEVEL = 8 + +# If the ALLOW_UNICODE_NAMES tag is set to YES, Doxygen will allow non-ASCII +# characters to appear in the names of generated files. If set to NO, non-ASCII +# characters will be escaped, for example _xE3_x81_x84 will be used for Unicode +# U+3044. +# The default value is: NO. + +ALLOW_UNICODE_NAMES = NO + +# The OUTPUT_LANGUAGE tag is used to specify the language in which all +# documentation generated by Doxygen is written. Doxygen will use this +# information to generate all constant output in the proper language. +# Possible values are: Afrikaans, Arabic, Armenian, Brazilian, Bulgarian, +# Catalan, Chinese, Chinese-Traditional, Croatian, Czech, Danish, Dutch, English +# (United States), Esperanto, Farsi (Persian), Finnish, French, German, Greek, +# Hindi, Hungarian, Indonesian, Italian, Japanese, Japanese-en (Japanese with +# English messages), Korean, Korean-en (Korean with English messages), Latvian, +# Lithuanian, Macedonian, Norwegian, Persian (Farsi), Polish, Portuguese, +# Romanian, Russian, Serbian, Serbian-Cyrillic, Slovak, Slovene, Spanish, +# Swedish, Turkish, Ukrainian and Vietnamese. +# The default value is: English. + +OUTPUT_LANGUAGE = English + +# If the BRIEF_MEMBER_DESC tag is set to YES, Doxygen will include brief member +# descriptions after the members that are listed in the file and class +# documentation (similar to Javadoc). Set to NO to disable this. +# The default value is: YES. + +BRIEF_MEMBER_DESC = YES + +# If the REPEAT_BRIEF tag is set to YES, Doxygen will prepend the brief +# description of a member or function before the detailed description +# +# Note: If both HIDE_UNDOC_MEMBERS and BRIEF_MEMBER_DESC are set to NO, the +# brief descriptions will be completely suppressed. +# The default value is: YES. + +REPEAT_BRIEF = YES + +# This tag implements a quasi-intelligent brief description abbreviator that is +# used to form the text in various listings. Each string in this list, if found +# as the leading text of the brief description, will be stripped from the text +# and the result, after processing the whole list, is used as the annotated +# text. Otherwise, the brief description is used as-is. If left blank, the +# following values are used ($name is automatically replaced with the name of +# the entity):The $name class, The $name widget, The $name file, is, provides, +# specifies, contains, represents, a, an and the. + +ABBREVIATE_BRIEF = "The $name class" \ + "The $name widget" \ + "The $name file" \ + is \ + provides \ + specifies \ + contains \ + represents \ + a \ + an \ + the + +# If the ALWAYS_DETAILED_SEC and REPEAT_BRIEF tags are both set to YES then +# Doxygen will generate a detailed section even if there is only a brief +# description. +# The default value is: NO. + +ALWAYS_DETAILED_SEC = NO + +# If the INLINE_INHERITED_MEMB tag is set to YES, Doxygen will show all +# inherited members of a class in the documentation of that class as if those +# members were ordinary class members. Constructors, destructors and assignment +# operators of the base classes will not be shown. +# The default value is: NO. + +INLINE_INHERITED_MEMB = NO + +# If the FULL_PATH_NAMES tag is set to YES, Doxygen will prepend the full path +# before files name in the file list and in the header files. If set to NO the +# shortest path that makes the file name unique will be used +# The default value is: YES. + +FULL_PATH_NAMES = YES + +# The STRIP_FROM_PATH tag can be used to strip a user-defined part of the path. +# Stripping is only done if one of the specified strings matches the left-hand +# part of the path. The tag can be used to show relative paths in the file list. +# If left blank the directory from which Doxygen is run is used as the path to +# strip. +# +# Note that you can specify absolute paths here, but also relative paths, which +# will be relative from the directory where Doxygen is started. +# This tag requires that the tag FULL_PATH_NAMES is set to YES. + +STRIP_FROM_PATH = + +# The STRIP_FROM_INC_PATH tag can be used to strip a user-defined part of the +# path mentioned in the documentation of a class, which tells the reader which +# header file to include in order to use a class. If left blank only the name of +# the header file containing the class definition is used. Otherwise one should +# specify the list of include paths that are normally passed to the compiler +# using the -I flag. + +STRIP_FROM_INC_PATH = + +# If the SHORT_NAMES tag is set to YES, Doxygen will generate much shorter (but +# less readable) file names. This can be useful if your file system doesn't +# support long names like on DOS, Mac, or CD-ROM. +# The default value is: NO. + +SHORT_NAMES = NO + +# If the JAVADOC_AUTOBRIEF tag is set to YES then Doxygen will interpret the +# first line (until the first dot, question mark or exclamation mark) of a +# Javadoc-style comment as the brief description. If set to NO, the Javadoc- +# style will behave just like regular Qt-style comments (thus requiring an +# explicit @brief command for a brief description.) +# The default value is: NO. + +JAVADOC_AUTOBRIEF = NO + +# If the JAVADOC_BANNER tag is set to YES then Doxygen will interpret a line +# such as +# /*************** +# as being the beginning of a Javadoc-style comment "banner". If set to NO, the +# Javadoc-style will behave just like regular comments and it will not be +# interpreted by Doxygen. +# The default value is: NO. + +JAVADOC_BANNER = NO + +# If the QT_AUTOBRIEF tag is set to YES then Doxygen will interpret the first +# line (until the first dot, question mark or exclamation mark) of a Qt-style +# comment as the brief description. If set to NO, the Qt-style will behave just +# like regular Qt-style comments (thus requiring an explicit \brief command for +# a brief description.) +# The default value is: NO. + +QT_AUTOBRIEF = NO + +# The MULTILINE_CPP_IS_BRIEF tag can be set to YES to make Doxygen treat a +# multi-line C++ special comment block (i.e. a block of //! or /// comments) as +# a brief description. This used to be the default behavior. The new default is +# to treat a multi-line C++ comment block as a detailed description. Set this +# tag to YES if you prefer the old behavior instead. +# +# Note that setting this tag to YES also means that rational rose comments are +# not recognized any more. +# The default value is: NO. + +MULTILINE_CPP_IS_BRIEF = NO + +# By default Python docstrings are displayed as preformatted text and Doxygen's +# special commands cannot be used. By setting PYTHON_DOCSTRING to NO the +# Doxygen's special commands can be used and the contents of the docstring +# documentation blocks is shown as Doxygen documentation. +# The default value is: YES. + +PYTHON_DOCSTRING = YES + +# If the INHERIT_DOCS tag is set to YES then an undocumented member inherits the +# documentation from any documented member that it re-implements. +# The default value is: YES. + +INHERIT_DOCS = YES + +# If the SEPARATE_MEMBER_PAGES tag is set to YES then Doxygen will produce a new +# page for each member. If set to NO, the documentation of a member will be part +# of the file/class/namespace that contains it. +# The default value is: NO. + +SEPARATE_MEMBER_PAGES = NO + +# The TAB_SIZE tag can be used to set the number of spaces in a tab. Doxygen +# uses this value to replace tabs by spaces in code fragments. +# Minimum value: 1, maximum value: 16, default value: 4. + +TAB_SIZE = 4 + +# This tag can be used to specify a number of aliases that act as commands in +# the documentation. An alias has the form: +# name=value +# For example adding +# "sideeffect=@par Side Effects:^^" +# will allow you to put the command \sideeffect (or @sideeffect) in the +# documentation, which will result in a user-defined paragraph with heading +# "Side Effects:". Note that you cannot put \n's in the value part of an alias +# to insert newlines (in the resulting output). You can put ^^ in the value part +# of an alias to insert a newline as if a physical newline was in the original +# file. When you need a literal { or } or , in the value part of an alias you +# have to escape them by means of a backslash (\), this can lead to conflicts +# with the commands \{ and \} for these it is advised to use the version @{ and +# @} or use a double escape (\\{ and \\}) + +ALIASES = + +# Set the OPTIMIZE_OUTPUT_FOR_C tag to YES if your project consists of C sources +# only. Doxygen will then generate output that is more tailored for C. For +# instance, some of the names that are used will be different. The list of all +# members will be omitted, etc. +# The default value is: NO. + +OPTIMIZE_OUTPUT_FOR_C = NO + +# Set the OPTIMIZE_OUTPUT_JAVA tag to YES if your project consists of Java or +# Python sources only. Doxygen will then generate output that is more tailored +# for that language. For instance, namespaces will be presented as packages, +# qualified scopes will look different, etc. +# The default value is: NO. + +OPTIMIZE_OUTPUT_JAVA = NO + +# Set the OPTIMIZE_FOR_FORTRAN tag to YES if your project consists of Fortran +# sources. Doxygen will then generate output that is tailored for Fortran. +# The default value is: NO. + +OPTIMIZE_FOR_FORTRAN = NO + +# Set the OPTIMIZE_OUTPUT_VHDL tag to YES if your project consists of VHDL +# sources. Doxygen will then generate output that is tailored for VHDL. +# The default value is: NO. + +OPTIMIZE_OUTPUT_VHDL = NO + +# Set the OPTIMIZE_OUTPUT_SLICE tag to YES if your project consists of Slice +# sources only. Doxygen will then generate output that is more tailored for that +# language. For instance, namespaces will be presented as modules, types will be +# separated into more groups, etc. +# The default value is: NO. + +OPTIMIZE_OUTPUT_SLICE = NO + +# Doxygen selects the parser to use depending on the extension of the files it +# parses. With this tag you can assign which parser to use for a given +# extension. Doxygen has a built-in mapping, but you can override or extend it +# using this tag. The format is ext=language, where ext is a file extension, and +# language is one of the parsers supported by Doxygen: IDL, Java, JavaScript, +# Csharp (C#), C, C++, Lex, D, PHP, md (Markdown), Objective-C, Python, Slice, +# VHDL, Fortran (fixed format Fortran: FortranFixed, free formatted Fortran: +# FortranFree, unknown formatted Fortran: Fortran. In the later case the parser +# tries to guess whether the code is fixed or free formatted code, this is the +# default for Fortran type files). For instance to make Doxygen treat .inc files +# as Fortran files (default is PHP), and .f files as C (default is Fortran), +# use: inc=Fortran f=C. +# +# Note: For files without extension you can use no_extension as a placeholder. +# +# Note that for custom extensions you also need to set FILE_PATTERNS otherwise +# the files are not read by Doxygen. When specifying no_extension you should add +# * to the FILE_PATTERNS. +# +# Note see also the list of default file extension mappings. + +EXTENSION_MAPPING = + +# If the MARKDOWN_SUPPORT tag is enabled then Doxygen pre-processes all comments +# according to the Markdown format, which allows for more readable +# documentation. See https://daringfireball.net/projects/markdown/ for details. +# The output of markdown processing is further processed by Doxygen, so you can +# mix Doxygen, HTML, and XML commands with Markdown formatting. Disable only in +# case of backward compatibilities issues. +# The default value is: YES. + +MARKDOWN_SUPPORT = YES + +# When the TOC_INCLUDE_HEADINGS tag is set to a non-zero value, all headings up +# to that level are automatically included in the table of contents, even if +# they do not have an id attribute. +# Note: This feature currently applies only to Markdown headings. +# Minimum value: 0, maximum value: 99, default value: 6. +# This tag requires that the tag MARKDOWN_SUPPORT is set to YES. + +TOC_INCLUDE_HEADINGS = 6 + +# The MARKDOWN_ID_STYLE tag can be used to specify the algorithm used to +# generate identifiers for the Markdown headings. Note: Every identifier is +# unique. +# Possible values are: DOXYGEN use a fixed 'autotoc_md' string followed by a +# sequence number starting at 0 and GITHUB use the lower case version of title +# with any whitespace replaced by '-' and punctuation characters removed. +# The default value is: DOXYGEN. +# This tag requires that the tag MARKDOWN_SUPPORT is set to YES. + +MARKDOWN_ID_STYLE = DOXYGEN + +# When enabled Doxygen tries to link words that correspond to documented +# classes, or namespaces to their corresponding documentation. Such a link can +# be prevented in individual cases by putting a % sign in front of the word or +# globally by setting AUTOLINK_SUPPORT to NO. Words listed in the +# AUTOLINK_IGNORE_WORDS tag are excluded from automatic linking. +# The default value is: YES. + +AUTOLINK_SUPPORT = YES + +# This tag specifies a list of words that, when matching the start of a word in +# the documentation, will suppress auto links generation, if it is enabled via +# AUTOLINK_SUPPORT. This list does not affect affect links explicitly created +# using \# or the \link or commands. +# This tag requires that the tag AUTOLINK_SUPPORT is set to YES. + +AUTOLINK_IGNORE_WORDS = + +# If you use STL classes (i.e. std::string, std::vector, etc.) but do not want +# to include (a tag file for) the STL sources as input, then you should set this +# tag to YES in order to let Doxygen match functions declarations and +# definitions whose arguments contain STL classes (e.g. func(std::string); +# versus func(std::string) {}). This also makes the inheritance and +# collaboration diagrams that involve STL classes more complete and accurate. +# The default value is: NO. + +BUILTIN_STL_SUPPORT = NO + +# If you use Microsoft's C++/CLI language, you should set this option to YES to +# enable parsing support. +# The default value is: NO. + +CPP_CLI_SUPPORT = NO + +# Set the SIP_SUPPORT tag to YES if your project consists of sip (see: +# https://www.riverbankcomputing.com/software) sources only. Doxygen will parse +# them like normal C++ but will assume all classes use public instead of private +# inheritance when no explicit protection keyword is present. +# The default value is: NO. + +SIP_SUPPORT = NO + +# For Microsoft's IDL there are propget and propput attributes to indicate +# getter and setter methods for a property. Setting this option to YES will make +# Doxygen to replace the get and set methods by a property in the documentation. +# This will only work if the methods are indeed getting or setting a simple +# type. If this is not the case, or you want to show the methods anyway, you +# should set this option to NO. +# The default value is: YES. + +IDL_PROPERTY_SUPPORT = YES + +# If member grouping is used in the documentation and the DISTRIBUTE_GROUP_DOC +# tag is set to YES then Doxygen will reuse the documentation of the first +# member in the group (if any) for the other members of the group. By default +# all members of a group must be documented explicitly. +# The default value is: NO. + +DISTRIBUTE_GROUP_DOC = NO + +# If one adds a struct or class to a group and this option is enabled, then also +# any nested class or struct is added to the same group. By default this option +# is disabled and one has to add nested compounds explicitly via \ingroup. +# The default value is: NO. + +GROUP_NESTED_COMPOUNDS = NO + +# Set the SUBGROUPING tag to YES to allow class member groups of the same type +# (for instance a group of public functions) to be put as a subgroup of that +# type (e.g. under the Public Functions section). Set it to NO to prevent +# subgrouping. Alternatively, this can be done per class using the +# \nosubgrouping command. +# The default value is: YES. + +SUBGROUPING = YES + +# When the INLINE_GROUPED_CLASSES tag is set to YES, classes, structs and unions +# are shown inside the group in which they are included (e.g. using \ingroup) +# instead of on a separate page (for HTML and Man pages) or section (for LaTeX +# and RTF). +# +# Note that this feature does not work in combination with +# SEPARATE_MEMBER_PAGES. +# The default value is: NO. + +INLINE_GROUPED_CLASSES = NO + +# When the INLINE_SIMPLE_STRUCTS tag is set to YES, structs, classes, and unions +# with only public data fields or simple typedef fields will be shown inline in +# the documentation of the scope in which they are defined (i.e. file, +# namespace, or group documentation), provided this scope is documented. If set +# to NO, structs, classes, and unions are shown on a separate page (for HTML and +# Man pages) or section (for LaTeX and RTF). +# The default value is: NO. + +INLINE_SIMPLE_STRUCTS = NO + +# When TYPEDEF_HIDES_STRUCT tag is enabled, a typedef of a struct, union, or +# enum is documented as struct, union, or enum with the name of the typedef. So +# typedef struct TypeS {} TypeT, will appear in the documentation as a struct +# with name TypeT. When disabled the typedef will appear as a member of a file, +# namespace, or class. And the struct will be named TypeS. This can typically be +# useful for C code in case the coding convention dictates that all compound +# types are typedef'ed and only the typedef is referenced, never the tag name. +# The default value is: NO. + +TYPEDEF_HIDES_STRUCT = NO + +# The size of the symbol lookup cache can be set using LOOKUP_CACHE_SIZE. This +# cache is used to resolve symbols given their name and scope. Since this can be +# an expensive process and often the same symbol appears multiple times in the +# code, Doxygen keeps a cache of pre-resolved symbols. If the cache is too small +# Doxygen will become slower. If the cache is too large, memory is wasted. The +# cache size is given by this formula: 2^(16+LOOKUP_CACHE_SIZE). The valid range +# is 0..9, the default is 0, corresponding to a cache size of 2^16=65536 +# symbols. At the end of a run Doxygen will report the cache usage and suggest +# the optimal cache size from a speed point of view. +# Minimum value: 0, maximum value: 9, default value: 0. + +LOOKUP_CACHE_SIZE = 0 + +# The NUM_PROC_THREADS specifies the number of threads Doxygen is allowed to use +# during processing. When set to 0 Doxygen will based this on the number of +# cores available in the system. You can set it explicitly to a value larger +# than 0 to get more control over the balance between CPU load and processing +# speed. At this moment only the input processing can be done using multiple +# threads. Since this is still an experimental feature the default is set to 1, +# which effectively disables parallel processing. Please report any issues you +# encounter. Generating dot graphs in parallel is controlled by the +# DOT_NUM_THREADS setting. +# Minimum value: 0, maximum value: 32, default value: 1. + +NUM_PROC_THREADS = 1 + +# If the TIMESTAMP tag is set different from NO then each generated page will +# contain the date or date and time when the page was generated. Setting this to +# NO can help when comparing the output of multiple runs. +# Possible values are: YES, NO, DATETIME and DATE. +# The default value is: NO. + +TIMESTAMP = NO + +#--------------------------------------------------------------------------- +# Build related configuration options +#--------------------------------------------------------------------------- + +# If the EXTRACT_ALL tag is set to YES, Doxygen will assume all entities in +# documentation are documented, even if no documentation was available. Private +# class members and static file members will be hidden unless the +# EXTRACT_PRIVATE respectively EXTRACT_STATIC tags are set to YES. +# Note: This will also disable the warnings about undocumented members that are +# normally produced when WARNINGS is set to YES. +# The default value is: NO. + +EXTRACT_ALL = YES + +# If the EXTRACT_PRIVATE tag is set to YES, all private members of a class will +# be included in the documentation. +# The default value is: NO. + +EXTRACT_PRIVATE = YES + +# If the EXTRACT_PRIV_VIRTUAL tag is set to YES, documented private virtual +# methods of a class will be included in the documentation. +# The default value is: NO. + +EXTRACT_PRIV_VIRTUAL = YES + +# If the EXTRACT_PACKAGE tag is set to YES, all members with package or internal +# scope will be included in the documentation. +# The default value is: NO. + +EXTRACT_PACKAGE = NO + +# If the EXTRACT_STATIC tag is set to YES, all static members of a file will be +# included in the documentation. +# The default value is: NO. + +EXTRACT_STATIC = YES + +# If the EXTRACT_LOCAL_CLASSES tag is set to YES, classes (and structs) defined +# locally in source files will be included in the documentation. If set to NO, +# only classes defined in header files are included. Does not have any effect +# for Java sources. +# The default value is: YES. + +EXTRACT_LOCAL_CLASSES = YES + +# This flag is only useful for Objective-C code. If set to YES, local methods, +# which are defined in the implementation section but not in the interface are +# included in the documentation. If set to NO, only methods in the interface are +# included. +# The default value is: NO. + +EXTRACT_LOCAL_METHODS = NO + +# If this flag is set to YES, the members of anonymous namespaces will be +# extracted and appear in the documentation as a namespace called +# 'anonymous_namespace{file}', where file will be replaced with the base name of +# the file that contains the anonymous namespace. By default anonymous namespace +# are hidden. +# The default value is: NO. + +EXTRACT_ANON_NSPACES = NO + +# If this flag is set to YES, the name of an unnamed parameter in a declaration +# will be determined by the corresponding definition. By default unnamed +# parameters remain unnamed in the output. +# The default value is: YES. + +RESOLVE_UNNAMED_PARAMS = YES + +# If the HIDE_UNDOC_MEMBERS tag is set to YES, Doxygen will hide all +# undocumented members inside documented classes or files. If set to NO these +# members will be included in the various overviews, but no documentation +# section is generated. This option has no effect if EXTRACT_ALL is enabled. +# The default value is: NO. + +HIDE_UNDOC_MEMBERS = NO + +# If the HIDE_UNDOC_CLASSES tag is set to YES, Doxygen will hide all +# undocumented classes that are normally visible in the class hierarchy. If set +# to NO, these classes will be included in the various overviews. This option +# will also hide undocumented C++ concepts if enabled. This option has no effect +# if EXTRACT_ALL is enabled. +# The default value is: NO. + +HIDE_UNDOC_CLASSES = NO + +# If the HIDE_UNDOC_NAMESPACES tag is set to YES, Doxygen will hide all +# undocumented namespaces that are normally visible in the namespace hierarchy. +# If set to NO, these namespaces will be included in the various overviews. This +# option has no effect if EXTRACT_ALL is enabled. +# The default value is: YES. + +HIDE_UNDOC_NAMESPACES = YES + +# If the HIDE_FRIEND_COMPOUNDS tag is set to YES, Doxygen will hide all friend +# declarations. If set to NO, these declarations will be included in the +# documentation. +# The default value is: NO. + +HIDE_FRIEND_COMPOUNDS = NO + +# If the HIDE_IN_BODY_DOCS tag is set to YES, Doxygen will hide any +# documentation blocks found inside the body of a function. If set to NO, these +# blocks will be appended to the function's detailed documentation block. +# The default value is: NO. + +HIDE_IN_BODY_DOCS = NO + +# The INTERNAL_DOCS tag determines if documentation that is typed after a +# \internal command is included. If the tag is set to NO then the documentation +# will be excluded. Set it to YES to include the internal documentation. +# The default value is: NO. + +INTERNAL_DOCS = NO + +# With the correct setting of option CASE_SENSE_NAMES Doxygen will better be +# able to match the capabilities of the underlying filesystem. In case the +# filesystem is case sensitive (i.e. it supports files in the same directory +# whose names only differ in casing), the option must be set to YES to properly +# deal with such files in case they appear in the input. For filesystems that +# are not case sensitive the option should be set to NO to properly deal with +# output files written for symbols that only differ in casing, such as for two +# classes, one named CLASS and the other named Class, and to also support +# references to files without having to specify the exact matching casing. On +# Windows (including Cygwin) and macOS, users should typically set this option +# to NO, whereas on Linux or other Unix flavors it should typically be set to +# YES. +# Possible values are: SYSTEM, NO and YES. +# The default value is: SYSTEM. + +CASE_SENSE_NAMES = SYSTEM + +# If the HIDE_SCOPE_NAMES tag is set to NO then Doxygen will show members with +# their full class and namespace scopes in the documentation. If set to YES, the +# scope will be hidden. +# The default value is: NO. + +HIDE_SCOPE_NAMES = NO + +# If the HIDE_COMPOUND_REFERENCE tag is set to NO (default) then Doxygen will +# append additional text to a page's title, such as Class Reference. If set to +# YES the compound reference will be hidden. +# The default value is: NO. + +HIDE_COMPOUND_REFERENCE= NO + +# If the SHOW_HEADERFILE tag is set to YES then the documentation for a class +# will show which file needs to be included to use the class. +# The default value is: YES. + +SHOW_HEADERFILE = YES + +# If the SHOW_INCLUDE_FILES tag is set to YES then Doxygen will put a list of +# the files that are included by a file in the documentation of that file. +# The default value is: YES. + +SHOW_INCLUDE_FILES = YES + +# If the SHOW_GROUPED_MEMB_INC tag is set to YES then Doxygen will add for each +# grouped member an include statement to the documentation, telling the reader +# which file to include in order to use the member. +# The default value is: NO. + +SHOW_GROUPED_MEMB_INC = NO + +# If the FORCE_LOCAL_INCLUDES tag is set to YES then Doxygen will list include +# files with double quotes in the documentation rather than with sharp brackets. +# The default value is: NO. + +FORCE_LOCAL_INCLUDES = NO + +# If the INLINE_INFO tag is set to YES then a tag [inline] is inserted in the +# documentation for inline members. +# The default value is: YES. + +INLINE_INFO = YES + +# If the SORT_MEMBER_DOCS tag is set to YES then Doxygen will sort the +# (detailed) documentation of file and class members alphabetically by member +# name. If set to NO, the members will appear in declaration order. +# The default value is: YES. + +SORT_MEMBER_DOCS = YES + +# If the SORT_BRIEF_DOCS tag is set to YES then Doxygen will sort the brief +# descriptions of file, namespace and class members alphabetically by member +# name. If set to NO, the members will appear in declaration order. Note that +# this will also influence the order of the classes in the class list. +# The default value is: NO. + +SORT_BRIEF_DOCS = NO + +# If the SORT_MEMBERS_CTORS_1ST tag is set to YES then Doxygen will sort the +# (brief and detailed) documentation of class members so that constructors and +# destructors are listed first. If set to NO the constructors will appear in the +# respective orders defined by SORT_BRIEF_DOCS and SORT_MEMBER_DOCS. +# Note: If SORT_BRIEF_DOCS is set to NO this option is ignored for sorting brief +# member documentation. +# Note: If SORT_MEMBER_DOCS is set to NO this option is ignored for sorting +# detailed member documentation. +# The default value is: NO. + +SORT_MEMBERS_CTORS_1ST = NO + +# If the SORT_GROUP_NAMES tag is set to YES then Doxygen will sort the hierarchy +# of group names into alphabetical order. If set to NO the group names will +# appear in their defined order. +# The default value is: NO. + +SORT_GROUP_NAMES = NO + +# If the SORT_BY_SCOPE_NAME tag is set to YES, the class list will be sorted by +# fully-qualified names, including namespaces. If set to NO, the class list will +# be sorted only by class name, not including the namespace part. +# Note: This option is not very useful if HIDE_SCOPE_NAMES is set to YES. +# Note: This option applies only to the class list, not to the alphabetical +# list. +# The default value is: NO. + +SORT_BY_SCOPE_NAME = NO + +# If the STRICT_PROTO_MATCHING option is enabled and Doxygen fails to do proper +# type resolution of all parameters of a function it will reject a match between +# the prototype and the implementation of a member function even if there is +# only one candidate or it is obvious which candidate to choose by doing a +# simple string match. By disabling STRICT_PROTO_MATCHING Doxygen will still +# accept a match between prototype and implementation in such cases. +# The default value is: NO. + +STRICT_PROTO_MATCHING = NO + +# The GENERATE_TODOLIST tag can be used to enable (YES) or disable (NO) the todo +# list. This list is created by putting \todo commands in the documentation. +# The default value is: YES. + +GENERATE_TODOLIST = YES + +# The GENERATE_TESTLIST tag can be used to enable (YES) or disable (NO) the test +# list. This list is created by putting \test commands in the documentation. +# The default value is: YES. + +GENERATE_TESTLIST = YES + +# The GENERATE_BUGLIST tag can be used to enable (YES) or disable (NO) the bug +# list. This list is created by putting \bug commands in the documentation. +# The default value is: YES. + +GENERATE_BUGLIST = YES + +# The GENERATE_DEPRECATEDLIST tag can be used to enable (YES) or disable (NO) +# the deprecated list. This list is created by putting \deprecated commands in +# the documentation. +# The default value is: YES. + +GENERATE_DEPRECATEDLIST= YES + +# The ENABLED_SECTIONS tag can be used to enable conditional documentation +# sections, marked by \if ... \endif and \cond +# ... \endcond blocks. + +ENABLED_SECTIONS = + +# The MAX_INITIALIZER_LINES tag determines the maximum number of lines that the +# initial value of a variable or macro / define can have for it to appear in the +# documentation. If the initializer consists of more lines than specified here +# it will be hidden. Use a value of 0 to hide initializers completely. The +# appearance of the value of individual variables and macros / defines can be +# controlled using \showinitializer or \hideinitializer command in the +# documentation regardless of this setting. +# Minimum value: 0, maximum value: 10000, default value: 30. + +MAX_INITIALIZER_LINES = 30 + +# Set the SHOW_USED_FILES tag to NO to disable the list of files generated at +# the bottom of the documentation of classes and structs. If set to YES, the +# list will mention the files that were used to generate the documentation. +# The default value is: YES. + +SHOW_USED_FILES = YES + +# Set the SHOW_FILES tag to NO to disable the generation of the Files page. This +# will remove the Files entry from the Quick Index and from the Folder Tree View +# (if specified). +# The default value is: YES. + +SHOW_FILES = YES + +# Set the SHOW_NAMESPACES tag to NO to disable the generation of the Namespaces +# page. This will remove the Namespaces entry from the Quick Index and from the +# Folder Tree View (if specified). +# The default value is: YES. + +SHOW_NAMESPACES = YES + +# The FILE_VERSION_FILTER tag can be used to specify a program or script that +# Doxygen should invoke to get the current version for each file (typically from +# the version control system). Doxygen will invoke the program by executing (via +# popen()) the command command input-file, where command is the value of the +# FILE_VERSION_FILTER tag, and input-file is the name of an input file provided +# by Doxygen. Whatever the program writes to standard output is used as the file +# version. For an example see the documentation. + +FILE_VERSION_FILTER = + +# The LAYOUT_FILE tag can be used to specify a layout file which will be parsed +# by Doxygen. The layout file controls the global structure of the generated +# output files in an output format independent way. To create the layout file +# that represents Doxygen's defaults, run Doxygen with the -l option. You can +# optionally specify a file name after the option, if omitted DoxygenLayout.xml +# will be used as the name of the layout file. See also section "Changing the +# layout of pages" for information. +# +# Note that if you run Doxygen from a directory containing a file called +# DoxygenLayout.xml, Doxygen will parse it automatically even if the LAYOUT_FILE +# tag is left empty. + +LAYOUT_FILE = + +# The CITE_BIB_FILES tag can be used to specify one or more bib files containing +# the reference definitions. This must be a list of .bib files. The .bib +# extension is automatically appended if omitted. This requires the bibtex tool +# to be installed. See also https://en.wikipedia.org/wiki/BibTeX for more info. +# For LaTeX the style of the bibliography can be controlled using +# LATEX_BIB_STYLE. To use this feature you need bibtex and perl available in the +# search path. See also \cite for info how to create references. + +CITE_BIB_FILES = + +# The EXTERNAL_TOOL_PATH tag can be used to extend the search path (PATH +# environment variable) so that external tools such as latex and gs can be +# found. +# Note: Directories specified with EXTERNAL_TOOL_PATH are added in front of the +# path already specified by the PATH variable, and are added in the order +# specified. +# Note: This option is particularly useful for macOS version 14 (Sonoma) and +# higher, when running Doxygen from Doxywizard, because in this case any user- +# defined changes to the PATH are ignored. A typical example on macOS is to set +# EXTERNAL_TOOL_PATH = /Library/TeX/texbin /usr/local/bin +# together with the standard path, the full search path used by doxygen when +# launching external tools will then become +# PATH=/Library/TeX/texbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + +EXTERNAL_TOOL_PATH = + +#--------------------------------------------------------------------------- +# Configuration options related to warning and progress messages +#--------------------------------------------------------------------------- + +# The QUIET tag can be used to turn on/off the messages that are generated to +# standard output by Doxygen. If QUIET is set to YES this implies that the +# messages are off. +# The default value is: NO. + +QUIET = NO + +# The WARNINGS tag can be used to turn on/off the warning messages that are +# generated to standard error (stderr) by Doxygen. If WARNINGS is set to YES +# this implies that the warnings are on. +# +# Tip: Turn warnings on while writing the documentation. +# The default value is: YES. + +WARNINGS = YES + +# If the WARN_IF_UNDOCUMENTED tag is set to YES then Doxygen will generate +# warnings for undocumented members. If EXTRACT_ALL is set to YES then this flag +# will automatically be disabled. +# The default value is: YES. + +WARN_IF_UNDOCUMENTED = YES + +# If the WARN_IF_DOC_ERROR tag is set to YES, Doxygen will generate warnings for +# potential errors in the documentation, such as documenting some parameters in +# a documented function twice, or documenting parameters that don't exist or +# using markup commands wrongly. +# The default value is: YES. + +WARN_IF_DOC_ERROR = YES + +# If WARN_IF_INCOMPLETE_DOC is set to YES, Doxygen will warn about incomplete +# function parameter documentation. If set to NO, Doxygen will accept that some +# parameters have no documentation without warning. +# The default value is: YES. + +WARN_IF_INCOMPLETE_DOC = YES + +# This WARN_NO_PARAMDOC option can be enabled to get warnings for functions that +# are documented, but have no documentation for their parameters or return +# value. If set to NO, Doxygen will only warn about wrong parameter +# documentation, but not about the absence of documentation. If EXTRACT_ALL is +# set to YES then this flag will automatically be disabled. See also +# WARN_IF_INCOMPLETE_DOC +# The default value is: NO. + +WARN_NO_PARAMDOC = NO + +# If WARN_IF_UNDOC_ENUM_VAL option is set to YES, Doxygen will warn about +# undocumented enumeration values. If set to NO, Doxygen will accept +# undocumented enumeration values. If EXTRACT_ALL is set to YES then this flag +# will automatically be disabled. +# The default value is: NO. + +WARN_IF_UNDOC_ENUM_VAL = NO + +# If WARN_LAYOUT_FILE option is set to YES, Doxygen will warn about issues found +# while parsing the user defined layout file, such as missing or wrong elements. +# See also LAYOUT_FILE for details. If set to NO, problems with the layout file +# will be suppressed. +# The default value is: YES. + +WARN_LAYOUT_FILE = YES + +# If the WARN_AS_ERROR tag is set to YES then Doxygen will immediately stop when +# a warning is encountered. If the WARN_AS_ERROR tag is set to FAIL_ON_WARNINGS +# then Doxygen will continue running as if WARN_AS_ERROR tag is set to NO, but +# at the end of the Doxygen process Doxygen will return with a non-zero status. +# If the WARN_AS_ERROR tag is set to FAIL_ON_WARNINGS_PRINT then Doxygen behaves +# like FAIL_ON_WARNINGS but in case no WARN_LOGFILE is defined Doxygen will not +# write the warning messages in between other messages but write them at the end +# of a run, in case a WARN_LOGFILE is defined the warning messages will be +# besides being in the defined file also be shown at the end of a run, unless +# the WARN_LOGFILE is defined as - i.e. standard output (stdout) in that case +# the behavior will remain as with the setting FAIL_ON_WARNINGS. +# Possible values are: NO, YES, FAIL_ON_WARNINGS and FAIL_ON_WARNINGS_PRINT. +# The default value is: NO. + +WARN_AS_ERROR = NO + +# The WARN_FORMAT tag determines the format of the warning messages that Doxygen +# can produce. The string should contain the $file, $line, and $text tags, which +# will be replaced by the file and line number from which the warning originated +# and the warning text. Optionally the format may contain $version, which will +# be replaced by the version of the file (if it could be obtained via +# FILE_VERSION_FILTER) +# See also: WARN_LINE_FORMAT +# The default value is: $file:$line: $text. + +WARN_FORMAT = "$file:$line: $text" + +# In the $text part of the WARN_FORMAT command it is possible that a reference +# to a more specific place is given. To make it easier to jump to this place +# (outside of Doxygen) the user can define a custom "cut" / "paste" string. +# Example: +# WARN_LINE_FORMAT = "'vi $file +$line'" +# See also: WARN_FORMAT +# The default value is: at line $line of file $file. + +WARN_LINE_FORMAT = "at line $line of file $file" + +# The WARN_LOGFILE tag can be used to specify a file to which warning and error +# messages should be written. If left blank the output is written to standard +# error (stderr). In case the file specified cannot be opened for writing the +# warning and error messages are written to standard error. When as file - is +# specified the warning and error messages are written to standard output +# (stdout). + +WARN_LOGFILE = + +#--------------------------------------------------------------------------- +# Configuration options related to the input files +#--------------------------------------------------------------------------- + +# The INPUT tag is used to specify the files and/or directories that contain +# documented source files. You may enter file names like myfile.cpp or +# directories like /usr/src/myproject. Separate the files or directories with +# spaces. See also FILE_PATTERNS and EXTENSION_MAPPING +# Note: If this tag is empty the current directory is searched. + +INPUT = src + +# This tag can be used to specify the character encoding of the source files +# that Doxygen parses. Internally Doxygen uses the UTF-8 encoding. Doxygen uses +# libiconv (or the iconv built into libc) for the transcoding. See the libiconv +# documentation (see: +# https://www.gnu.org/software/libiconv/) for the list of possible encodings. +# See also: INPUT_FILE_ENCODING +# The default value is: UTF-8. + +INPUT_ENCODING = UTF-8 + +# This tag can be used to specify the character encoding of the source files +# that Doxygen parses. The INPUT_FILE_ENCODING tag can be used to specify +# character encoding on a per file pattern basis. Doxygen will compare the file +# name with each pattern and apply the encoding instead of the default +# INPUT_ENCODING if there is a match. The character encodings are a list of the +# form: pattern=encoding (like *.php=ISO-8859-1). +# See also: INPUT_ENCODING for further information on supported encodings. + +INPUT_FILE_ENCODING = + +# If the value of the INPUT tag contains directories, you can use the +# FILE_PATTERNS tag to specify one or more wildcard patterns (like *.cpp and +# *.h) to filter out the source-files in the directories. +# +# Note that for custom extensions or not directly supported extensions you also +# need to set EXTENSION_MAPPING for the extension otherwise the files are not +# read by Doxygen. +# +# Note the list of default checked file patterns might differ from the list of +# default file extension mappings. +# +# If left blank the following patterns are tested:*.c, *.cc, *.cxx, *.cxxm, +# *.cpp, *.cppm, *.ccm, *.c++, *.c++m, *.java, *.ii, *.ixx, *.ipp, *.i++, *.inl, +# *.idl, *.ddl, *.odl, *.h, *.hh, *.hxx, *.hpp, *.h++, *.ixx, *.l, *.cs, *.d, +# *.php, *.php4, *.php5, *.phtml, *.inc, *.m, *.markdown, *.md, *.mm, *.dox (to +# be provided as Doxygen C comment), *.py, *.pyw, *.f90, *.f95, *.f03, *.f08, +# *.f18, *.f, *.for, *.vhd, *.vhdl, *.ucf, *.qsf and *.ice. + +FILE_PATTERNS = *.c \ + *.cc \ + *.cxx \ + *.cxxm \ + *.cpp \ + *.cppm \ + *.ccm \ + *.c++ \ + *.c++m \ + *.java \ + *.ii \ + *.ixx \ + *.ipp \ + *.i++ \ + *.inl \ + *.idl \ + *.ddl \ + *.odl \ + *.h \ + *.hh \ + *.hxx \ + *.hpp \ + *.h++ \ + *.ixx \ + *.l \ + *.cs \ + *.d \ + *.php \ + *.php4 \ + *.php5 \ + *.phtml \ + *.inc \ + *.m \ + *.markdown \ + *.md \ + *.mm \ + *.dox \ + *.py \ + *.pyw \ + *.f90 \ + *.f95 \ + *.f03 \ + *.f08 \ + *.f18 \ + *.f \ + *.for \ + *.vhd \ + *.vhdl \ + *.ucf \ + *.qsf \ + *.ice + +# The RECURSIVE tag can be used to specify whether or not subdirectories should +# be searched for input files as well. +# The default value is: NO. + +RECURSIVE = YES + +# The EXCLUDE tag can be used to specify files and/or directories that should be +# excluded from the INPUT source files. This way you can easily exclude a +# subdirectory from a directory tree whose root is specified with the INPUT tag. +# +# Note that relative paths are relative to the directory from which Doxygen is +# run. + +EXCLUDE = + +# The EXCLUDE_SYMLINKS tag can be used to select whether or not files or +# directories that are symbolic links (a Unix file system feature) are excluded +# from the input. +# The default value is: NO. + +EXCLUDE_SYMLINKS = NO + +# If the value of the INPUT tag contains directories, you can use the +# EXCLUDE_PATTERNS tag to specify one or more wildcard patterns to exclude +# certain files from those directories. +# +# Note that the wildcards are matched against the file with absolute path, so to +# exclude all test directories for example use the pattern */test/* + +EXCLUDE_PATTERNS = + +# The EXCLUDE_SYMBOLS tag can be used to specify one or more symbol names +# (namespaces, classes, functions, etc.) that should be excluded from the +# output. The symbol name can be a fully qualified name, a word, or if the +# wildcard * is used, a substring. Examples: ANamespace, AClass, +# ANamespace::AClass, ANamespace::*Test + +EXCLUDE_SYMBOLS = + +# The EXAMPLE_PATH tag can be used to specify one or more files or directories +# that contain example code fragments that are included (see the \include +# command). + +EXAMPLE_PATH = + +# If the value of the EXAMPLE_PATH tag contains directories, you can use the +# EXAMPLE_PATTERNS tag to specify one or more wildcard pattern (like *.cpp and +# *.h) to filter out the source-files in the directories. If left blank all +# files are included. + +EXAMPLE_PATTERNS = * + +# If the EXAMPLE_RECURSIVE tag is set to YES then subdirectories will be +# searched for input files to be used with the \include or \dontinclude commands +# irrespective of the value of the RECURSIVE tag. +# The default value is: NO. + +EXAMPLE_RECURSIVE = NO + +# The IMAGE_PATH tag can be used to specify one or more files or directories +# that contain images that are to be included in the documentation (see the +# \image command). + +IMAGE_PATH = + +# The INPUT_FILTER tag can be used to specify a program that Doxygen should +# invoke to filter for each input file. Doxygen will invoke the filter program +# by executing (via popen()) the command: +# +# +# +# where is the value of the INPUT_FILTER tag, and is the +# name of an input file. Doxygen will then use the output that the filter +# program writes to standard output. If FILTER_PATTERNS is specified, this tag +# will be ignored. +# +# Note that the filter must not add or remove lines; it is applied before the +# code is scanned, but not when the output code is generated. If lines are added +# or removed, the anchors will not be placed correctly. +# +# Note that Doxygen will use the data processed and written to standard output +# for further processing, therefore nothing else, like debug statements or used +# commands (so in case of a Windows batch file always use @echo OFF), should be +# written to standard output. +# +# Note that for custom extensions or not directly supported extensions you also +# need to set EXTENSION_MAPPING for the extension otherwise the files are not +# properly processed by Doxygen. + +INPUT_FILTER = + +# The FILTER_PATTERNS tag can be used to specify filters on a per file pattern +# basis. Doxygen will compare the file name with each pattern and apply the +# filter if there is a match. The filters are a list of the form: pattern=filter +# (like *.cpp=my_cpp_filter). See INPUT_FILTER for further information on how +# filters are used. If the FILTER_PATTERNS tag is empty or if none of the +# patterns match the file name, INPUT_FILTER is applied. +# +# Note that for custom extensions or not directly supported extensions you also +# need to set EXTENSION_MAPPING for the extension otherwise the files are not +# properly processed by Doxygen. + +FILTER_PATTERNS = + +# If the FILTER_SOURCE_FILES tag is set to YES, the input filter (if set using +# INPUT_FILTER) will also be used to filter the input files that are used for +# producing the source files to browse (i.e. when SOURCE_BROWSER is set to YES). +# The default value is: NO. + +FILTER_SOURCE_FILES = NO + +# The FILTER_SOURCE_PATTERNS tag can be used to specify source filters per file +# pattern. A pattern will override the setting for FILTER_PATTERN (if any) and +# it is also possible to disable source filtering for a specific pattern using +# *.ext= (so without naming a filter). +# This tag requires that the tag FILTER_SOURCE_FILES is set to YES. + +FILTER_SOURCE_PATTERNS = + +# If the USE_MDFILE_AS_MAINPAGE tag refers to the name of a markdown file that +# is part of the input, its contents will be placed on the main page +# (index.html). This can be useful if you have a project on for instance GitHub +# and want to reuse the introduction page also for the Doxygen output. + +USE_MDFILE_AS_MAINPAGE = + +# If the IMPLICIT_DIR_DOCS tag is set to YES, any README.md file found in sub- +# directories of the project's root, is used as the documentation for that sub- +# directory, except when the README.md starts with a \dir, \page or \mainpage +# command. If set to NO, the README.md file needs to start with an explicit \dir +# command in order to be used as directory documentation. +# The default value is: YES. + +IMPLICIT_DIR_DOCS = YES + +# The Fortran standard specifies that for fixed formatted Fortran code all +# characters from position 72 are to be considered as comment. A common +# extension is to allow longer lines before the automatic comment starts. The +# setting FORTRAN_COMMENT_AFTER will also make it possible that longer lines can +# be processed before the automatic comment starts. +# Minimum value: 7, maximum value: 10000, default value: 72. + +FORTRAN_COMMENT_AFTER = 72 + +#--------------------------------------------------------------------------- +# Configuration options related to source browsing +#--------------------------------------------------------------------------- + +# If the SOURCE_BROWSER tag is set to YES then a list of source files will be +# generated. Documented entities will be cross-referenced with these sources. +# +# Note: To get rid of all source code in the generated output, make sure that +# also VERBATIM_HEADERS is set to NO. +# The default value is: NO. + +SOURCE_BROWSER = NO + +# Setting the INLINE_SOURCES tag to YES will include the body of functions, +# multi-line macros, enums or list initialized variables directly into the +# documentation. +# The default value is: NO. + +INLINE_SOURCES = NO + +# Setting the STRIP_CODE_COMMENTS tag to YES will instruct Doxygen to hide any +# special comment blocks from generated source code fragments. Normal C, C++ and +# Fortran comments will always remain visible. +# The default value is: YES. + +STRIP_CODE_COMMENTS = YES + +# If the REFERENCED_BY_RELATION tag is set to YES then for each documented +# entity all documented functions referencing it will be listed. +# The default value is: NO. + +REFERENCED_BY_RELATION = NO + +# If the REFERENCES_RELATION tag is set to YES then for each documented function +# all documented entities called/used by that function will be listed. +# The default value is: NO. + +REFERENCES_RELATION = NO + +# If the REFERENCES_LINK_SOURCE tag is set to YES and SOURCE_BROWSER tag is set +# to YES then the hyperlinks from functions in REFERENCES_RELATION and +# REFERENCED_BY_RELATION lists will link to the source code. Otherwise they will +# link to the documentation. +# The default value is: YES. + +REFERENCES_LINK_SOURCE = YES + +# If SOURCE_TOOLTIPS is enabled (the default) then hovering a hyperlink in the +# source code will show a tooltip with additional information such as prototype, +# brief description and links to the definition and documentation. Since this +# will make the HTML file larger and loading of large files a bit slower, you +# can opt to disable this feature. +# The default value is: YES. +# This tag requires that the tag SOURCE_BROWSER is set to YES. + +SOURCE_TOOLTIPS = YES + +# If the USE_HTAGS tag is set to YES then the references to source code will +# point to the HTML generated by the htags(1) tool instead of Doxygen built-in +# source browser. The htags tool is part of GNU's global source tagging system +# (see https://www.gnu.org/software/global/global.html). You will need version +# 4.8.6 or higher. +# +# To use it do the following: +# - Install the latest version of global +# - Enable SOURCE_BROWSER and USE_HTAGS in the configuration file +# - Make sure the INPUT points to the root of the source tree +# - Run doxygen as normal +# +# Doxygen will invoke htags (and that will in turn invoke gtags), so these +# tools must be available from the command line (i.e. in the search path). +# +# The result: instead of the source browser generated by Doxygen, the links to +# source code will now point to the output of htags. +# The default value is: NO. +# This tag requires that the tag SOURCE_BROWSER is set to YES. + +USE_HTAGS = NO + +# If the VERBATIM_HEADERS tag is set the YES then Doxygen will generate a +# verbatim copy of the header file for each class for which an include is +# specified. Set to NO to disable this. +# See also: Section \class. +# The default value is: YES. + +VERBATIM_HEADERS = YES + +#--------------------------------------------------------------------------- +# Configuration options related to the alphabetical class index +#--------------------------------------------------------------------------- + +# If the ALPHABETICAL_INDEX tag is set to YES, an alphabetical index of all +# compounds will be generated. Enable this if the project contains a lot of +# classes, structs, unions or interfaces. +# The default value is: YES. + +ALPHABETICAL_INDEX = YES + +# The IGNORE_PREFIX tag can be used to specify a prefix (or a list of prefixes) +# that should be ignored while generating the index headers. The IGNORE_PREFIX +# tag works for classes, function and member names. The entity will be placed in +# the alphabetical list under the first letter of the entity name that remains +# after removing the prefix. +# This tag requires that the tag ALPHABETICAL_INDEX is set to YES. + +IGNORE_PREFIX = + +#--------------------------------------------------------------------------- +# Configuration options related to the HTML output +#--------------------------------------------------------------------------- + +# If the GENERATE_HTML tag is set to YES, Doxygen will generate HTML output +# The default value is: YES. + +GENERATE_HTML = YES + +# The HTML_OUTPUT tag is used to specify where the HTML docs will be put. If a +# relative path is entered the value of OUTPUT_DIRECTORY will be put in front of +# it. +# The default directory is: html. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_OUTPUT = html + +# The HTML_FILE_EXTENSION tag can be used to specify the file extension for each +# generated HTML page (for example: .htm, .php, .asp). +# The default value is: .html. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_FILE_EXTENSION = .html + +# The HTML_HEADER tag can be used to specify a user-defined HTML header file for +# each generated HTML page. If the tag is left blank Doxygen will generate a +# standard header. +# +# To get valid HTML the header file that includes any scripts and style sheets +# that Doxygen needs, which is dependent on the configuration options used (e.g. +# the setting GENERATE_TREEVIEW). It is highly recommended to start with a +# default header using +# doxygen -w html new_header.html new_footer.html new_stylesheet.css +# YourConfigFile +# and then modify the file new_header.html. See also section "Doxygen usage" +# for information on how to generate the default header that Doxygen normally +# uses. +# Note: The header is subject to change so you typically have to regenerate the +# default header when upgrading to a newer version of Doxygen. For a description +# of the possible markers and block names see the documentation. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_HEADER = + +# The HTML_FOOTER tag can be used to specify a user-defined HTML footer for each +# generated HTML page. If the tag is left blank Doxygen will generate a standard +# footer. See HTML_HEADER for more information on how to generate a default +# footer and what special commands can be used inside the footer. See also +# section "Doxygen usage" for information on how to generate the default footer +# that Doxygen normally uses. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_FOOTER = + +# The HTML_STYLESHEET tag can be used to specify a user-defined cascading style +# sheet that is used by each HTML page. It can be used to fine-tune the look of +# the HTML output. If left blank Doxygen will generate a default style sheet. +# See also section "Doxygen usage" for information on how to generate the style +# sheet that Doxygen normally uses. +# Note: It is recommended to use HTML_EXTRA_STYLESHEET instead of this tag, as +# it is more robust and this tag (HTML_STYLESHEET) will in the future become +# obsolete. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_STYLESHEET = + +# The HTML_EXTRA_STYLESHEET tag can be used to specify additional user-defined +# cascading style sheets that are included after the standard style sheets +# created by Doxygen. Using this option one can overrule certain style aspects. +# This is preferred over using HTML_STYLESHEET since it does not replace the +# standard style sheet and is therefore more robust against future updates. +# Doxygen will copy the style sheet files to the output directory. +# Note: The order of the extra style sheet files is of importance (e.g. the last +# style sheet in the list overrules the setting of the previous ones in the +# list). +# Note: Since the styling of scrollbars can currently not be overruled in +# Webkit/Chromium, the styling will be left out of the default doxygen.css if +# one or more extra stylesheets have been specified. So if scrollbar +# customization is desired it has to be added explicitly. For an example see the +# documentation. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_EXTRA_STYLESHEET = + +# The HTML_EXTRA_FILES tag can be used to specify one or more extra images or +# other source files which should be copied to the HTML output directory. Note +# that these files will be copied to the base HTML output directory. Use the +# $relpath^ marker in the HTML_HEADER and/or HTML_FOOTER files to load these +# files. In the HTML_STYLESHEET file, use the file name only. Also note that the +# files will be copied as-is; there are no commands or markers available. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_EXTRA_FILES = + +# The HTML_COLORSTYLE tag can be used to specify if the generated HTML output +# should be rendered with a dark or light theme. +# Possible values are: LIGHT always generates light mode output, DARK always +# generates dark mode output, AUTO_LIGHT automatically sets the mode according +# to the user preference, uses light mode if no preference is set (the default), +# AUTO_DARK automatically sets the mode according to the user preference, uses +# dark mode if no preference is set and TOGGLE allows a user to switch between +# light and dark mode via a button. +# The default value is: AUTO_LIGHT. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_COLORSTYLE = AUTO_LIGHT + +# The HTML_COLORSTYLE_HUE tag controls the color of the HTML output. Doxygen +# will adjust the colors in the style sheet and background images according to +# this color. Hue is specified as an angle on a color-wheel, see +# https://en.wikipedia.org/wiki/Hue for more information. For instance the value +# 0 represents red, 60 is yellow, 120 is green, 180 is cyan, 240 is blue, 300 +# purple, and 360 is red again. +# Minimum value: 0, maximum value: 359, default value: 220. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_COLORSTYLE_HUE = 220 + +# The HTML_COLORSTYLE_SAT tag controls the purity (or saturation) of the colors +# in the HTML output. For a value of 0 the output will use gray-scales only. A +# value of 255 will produce the most vivid colors. +# Minimum value: 0, maximum value: 255, default value: 100. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_COLORSTYLE_SAT = 100 + +# The HTML_COLORSTYLE_GAMMA tag controls the gamma correction applied to the +# luminance component of the colors in the HTML output. Values below 100 +# gradually make the output lighter, whereas values above 100 make the output +# darker. The value divided by 100 is the actual gamma applied, so 80 represents +# a gamma of 0.8, The value 220 represents a gamma of 2.2, and 100 does not +# change the gamma. +# Minimum value: 40, maximum value: 240, default value: 80. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_COLORSTYLE_GAMMA = 80 + +# If the HTML_DYNAMIC_MENUS tag is set to YES then the generated HTML +# documentation will contain a main index with vertical navigation menus that +# are dynamically created via JavaScript. If disabled, the navigation index will +# consists of multiple levels of tabs that are statically embedded in every HTML +# page. Disable this option to support browsers that do not have JavaScript, +# like the Qt help browser. +# The default value is: YES. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_DYNAMIC_MENUS = YES + +# If the HTML_DYNAMIC_SECTIONS tag is set to YES then the generated HTML +# documentation will contain sections that can be hidden and shown after the +# page has loaded. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_DYNAMIC_SECTIONS = NO + +# If the HTML_CODE_FOLDING tag is set to YES then classes and functions can be +# dynamically folded and expanded in the generated HTML source code. +# The default value is: YES. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_CODE_FOLDING = YES + +# If the HTML_COPY_CLIPBOARD tag is set to YES then Doxygen will show an icon in +# the top right corner of code and text fragments that allows the user to copy +# its content to the clipboard. Note this only works if supported by the browser +# and the web page is served via a secure context (see: +# https://www.w3.org/TR/secure-contexts/), i.e. using the https: or file: +# protocol. +# The default value is: YES. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_COPY_CLIPBOARD = YES + +# Doxygen stores a couple of settings persistently in the browser (via e.g. +# cookies). By default these settings apply to all HTML pages generated by +# Doxygen across all projects. The HTML_PROJECT_COOKIE tag can be used to store +# the settings under a project specific key, such that the user preferences will +# be stored separately. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_PROJECT_COOKIE = + +# With HTML_INDEX_NUM_ENTRIES one can control the preferred number of entries +# shown in the various tree structured indices initially; the user can expand +# and collapse entries dynamically later on. Doxygen will expand the tree to +# such a level that at most the specified number of entries are visible (unless +# a fully collapsed tree already exceeds this amount). So setting the number of +# entries 1 will produce a full collapsed tree by default. 0 is a special value +# representing an infinite number of entries and will result in a full expanded +# tree by default. +# Minimum value: 0, maximum value: 9999, default value: 100. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_INDEX_NUM_ENTRIES = 100 + +# If the GENERATE_DOCSET tag is set to YES, additional index files will be +# generated that can be used as input for Apple's Xcode 3 integrated development +# environment (see: +# https://developer.apple.com/xcode/), introduced with OSX 10.5 (Leopard). To +# create a documentation set, Doxygen will generate a Makefile in the HTML +# output directory. Running make will produce the docset in that directory and +# running make install will install the docset in +# ~/Library/Developer/Shared/Documentation/DocSets so that Xcode will find it at +# startup. See https://developer.apple.com/library/archive/featuredarticles/Doxy +# genXcode/_index.html for more information. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_DOCSET = NO + +# This tag determines the name of the docset feed. A documentation feed provides +# an umbrella under which multiple documentation sets from a single provider +# (such as a company or product suite) can be grouped. +# The default value is: Doxygen generated docs. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_FEEDNAME = "Doxygen generated docs" + +# This tag determines the URL of the docset feed. A documentation feed provides +# an umbrella under which multiple documentation sets from a single provider +# (such as a company or product suite) can be grouped. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_FEEDURL = + +# This tag specifies a string that should uniquely identify the documentation +# set bundle. This should be a reverse domain-name style string, e.g. +# com.mycompany.MyDocSet. Doxygen will append .docset to the name. +# The default value is: org.doxygen.Project. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_BUNDLE_ID = org.doxygen.Project + +# The DOCSET_PUBLISHER_ID tag specifies a string that should uniquely identify +# the documentation publisher. This should be a reverse domain-name style +# string, e.g. com.mycompany.MyDocSet.documentation. +# The default value is: org.doxygen.Publisher. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_PUBLISHER_ID = org.doxygen.Publisher + +# The DOCSET_PUBLISHER_NAME tag identifies the documentation publisher. +# The default value is: Publisher. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_PUBLISHER_NAME = Publisher + +# If the GENERATE_HTMLHELP tag is set to YES then Doxygen generates three +# additional HTML index files: index.hhp, index.hhc, and index.hhk. The +# index.hhp is a project file that can be read by Microsoft's HTML Help Workshop +# on Windows. In the beginning of 2021 Microsoft took the original page, with +# a.o. the download links, offline (the HTML help workshop was already many +# years in maintenance mode). You can download the HTML help workshop from the +# web archives at Installation executable (see: +# http://web.archive.org/web/20160201063255/http://download.microsoft.com/downlo +# ad/0/A/9/0A939EF6-E31C-430F-A3DF-DFAE7960D564/htmlhelp.exe). +# +# The HTML Help Workshop contains a compiler that can convert all HTML output +# generated by Doxygen into a single compiled HTML file (.chm). Compiled HTML +# files are now used as the Windows 98 help format, and will replace the old +# Windows help format (.hlp) on all Windows platforms in the future. Compressed +# HTML files also contain an index, a table of contents, and you can search for +# words in the documentation. The HTML workshop also contains a viewer for +# compressed HTML files. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_HTMLHELP = NO + +# The CHM_FILE tag can be used to specify the file name of the resulting .chm +# file. You can add a path in front of the file if the result should not be +# written to the html output directory. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +CHM_FILE = + +# The HHC_LOCATION tag can be used to specify the location (absolute path +# including file name) of the HTML help compiler (hhc.exe). If non-empty, +# Doxygen will try to run the HTML help compiler on the generated index.hhp. +# The file has to be specified with full path. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +HHC_LOCATION = + +# The GENERATE_CHI flag controls if a separate .chi index file is generated +# (YES) or that it should be included in the main .chm file (NO). +# The default value is: NO. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +GENERATE_CHI = NO + +# The CHM_INDEX_ENCODING is used to encode HtmlHelp index (hhk), content (hhc) +# and project file content. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +CHM_INDEX_ENCODING = + +# The BINARY_TOC flag controls whether a binary table of contents is generated +# (YES) or a normal table of contents (NO) in the .chm file. Furthermore it +# enables the Previous and Next buttons. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +BINARY_TOC = NO + +# The TOC_EXPAND flag can be set to YES to add extra items for group members to +# the table of contents of the HTML help documentation and to the tree view. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +TOC_EXPAND = NO + +# The SITEMAP_URL tag is used to specify the full URL of the place where the +# generated documentation will be placed on the server by the user during the +# deployment of the documentation. The generated sitemap is called sitemap.xml +# and placed on the directory specified by HTML_OUTPUT. In case no SITEMAP_URL +# is specified no sitemap is generated. For information about the sitemap +# protocol see https://www.sitemaps.org +# This tag requires that the tag GENERATE_HTML is set to YES. + +SITEMAP_URL = + +# If the GENERATE_QHP tag is set to YES and both QHP_NAMESPACE and +# QHP_VIRTUAL_FOLDER are set, an additional index file will be generated that +# can be used as input for Qt's qhelpgenerator to generate a Qt Compressed Help +# (.qch) of the generated HTML documentation. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_QHP = NO + +# If the QHG_LOCATION tag is specified, the QCH_FILE tag can be used to specify +# the file name of the resulting .qch file. The path specified is relative to +# the HTML output folder. +# This tag requires that the tag GENERATE_QHP is set to YES. + +QCH_FILE = + +# The QHP_NAMESPACE tag specifies the namespace to use when generating Qt Help +# Project output. For more information please see Qt Help Project / Namespace +# (see: +# https://doc.qt.io/archives/qt-4.8/qthelpproject.html#namespace). +# The default value is: org.doxygen.Project. +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_NAMESPACE = org.doxygen.Project + +# The QHP_VIRTUAL_FOLDER tag specifies the namespace to use when generating Qt +# Help Project output. For more information please see Qt Help Project / Virtual +# Folders (see: +# https://doc.qt.io/archives/qt-4.8/qthelpproject.html#virtual-folders). +# The default value is: doc. +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_VIRTUAL_FOLDER = doc + +# If the QHP_CUST_FILTER_NAME tag is set, it specifies the name of a custom +# filter to add. For more information please see Qt Help Project / Custom +# Filters (see: +# https://doc.qt.io/archives/qt-4.8/qthelpproject.html#custom-filters). +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_CUST_FILTER_NAME = + +# The QHP_CUST_FILTER_ATTRS tag specifies the list of the attributes of the +# custom filter to add. For more information please see Qt Help Project / Custom +# Filters (see: +# https://doc.qt.io/archives/qt-4.8/qthelpproject.html#custom-filters). +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_CUST_FILTER_ATTRS = + +# The QHP_SECT_FILTER_ATTRS tag specifies the list of the attributes this +# project's filter section matches. Qt Help Project / Filter Attributes (see: +# https://doc.qt.io/archives/qt-4.8/qthelpproject.html#filter-attributes). +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_SECT_FILTER_ATTRS = + +# The QHG_LOCATION tag can be used to specify the location (absolute path +# including file name) of Qt's qhelpgenerator. If non-empty Doxygen will try to +# run qhelpgenerator on the generated .qhp file. +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHG_LOCATION = + +# If the GENERATE_ECLIPSEHELP tag is set to YES, additional index files will be +# generated, together with the HTML files, they form an Eclipse help plugin. To +# install this plugin and make it available under the help contents menu in +# Eclipse, the contents of the directory containing the HTML and XML files needs +# to be copied into the plugins directory of eclipse. The name of the directory +# within the plugins directory should be the same as the ECLIPSE_DOC_ID value. +# After copying Eclipse needs to be restarted before the help appears. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_ECLIPSEHELP = NO + +# A unique identifier for the Eclipse help plugin. When installing the plugin +# the directory name containing the HTML and XML files should also have this +# name. Each documentation set should have its own identifier. +# The default value is: org.doxygen.Project. +# This tag requires that the tag GENERATE_ECLIPSEHELP is set to YES. + +ECLIPSE_DOC_ID = org.doxygen.Project + +# If you want full control over the layout of the generated HTML pages it might +# be necessary to disable the index and replace it with your own. The +# DISABLE_INDEX tag can be used to turn on/off the condensed index (tabs) at top +# of each HTML page. A value of NO enables the index and the value YES disables +# it. Since the tabs in the index contain the same information as the navigation +# tree, you can set this option to YES if you also set GENERATE_TREEVIEW to YES. +# The default value is: YES. +# This tag requires that the tag GENERATE_HTML is set to YES. + +DISABLE_INDEX = YES + +# The GENERATE_TREEVIEW tag is used to specify whether a tree-like index +# structure should be generated to display hierarchical information. If the tag +# value is set to YES, a side panel will be generated containing a tree-like +# index structure (just like the one that is generated for HTML Help). For this +# to work a browser that supports JavaScript, DHTML, CSS and frames is required +# (i.e. any modern browser). Windows users are probably better off using the +# HTML help feature. Via custom style sheets (see HTML_EXTRA_STYLESHEET) one can +# further fine tune the look of the index (see "Fine-tuning the output"). As an +# example, the default style sheet generated by Doxygen has an example that +# shows how to put an image at the root of the tree instead of the PROJECT_NAME. +# Since the tree basically has the same information as the tab index, you could +# consider setting DISABLE_INDEX to YES when enabling this option. +# The default value is: YES. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_TREEVIEW = YES + +# When both GENERATE_TREEVIEW and DISABLE_INDEX are set to YES, then the +# FULL_SIDEBAR option determines if the side bar is limited to only the treeview +# area (value NO) or if it should extend to the full height of the window (value +# YES). Setting this to YES gives a layout similar to +# https://docs.readthedocs.io with more room for contents, but less room for the +# project logo, title, and description. If either GENERATE_TREEVIEW or +# DISABLE_INDEX is set to NO, this option has no effect. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +FULL_SIDEBAR = NO + +# The ENUM_VALUES_PER_LINE tag can be used to set the number of enum values that +# Doxygen will group on one line in the generated HTML documentation. +# +# Note that a value of 0 will completely suppress the enum values from appearing +# in the overview section. +# Minimum value: 0, maximum value: 20, default value: 4. +# This tag requires that the tag GENERATE_HTML is set to YES. + +ENUM_VALUES_PER_LINE = 4 + +# When the SHOW_ENUM_VALUES tag is set doxygen will show the specified +# enumeration values besides the enumeration mnemonics. +# The default value is: NO. + +SHOW_ENUM_VALUES = NO + +# If the treeview is enabled (see GENERATE_TREEVIEW) then this tag can be used +# to set the initial width (in pixels) of the frame in which the tree is shown. +# Minimum value: 0, maximum value: 1500, default value: 250. +# This tag requires that the tag GENERATE_HTML is set to YES. + +TREEVIEW_WIDTH = 250 + +# If the EXT_LINKS_IN_WINDOW option is set to YES, Doxygen will open links to +# external symbols imported via tag files in a separate window. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +EXT_LINKS_IN_WINDOW = NO + +# If the OBFUSCATE_EMAILS tag is set to YES, Doxygen will obfuscate email +# addresses. +# The default value is: YES. +# This tag requires that the tag GENERATE_HTML is set to YES. + +OBFUSCATE_EMAILS = YES + +# If the HTML_FORMULA_FORMAT option is set to svg, Doxygen will use the pdf2svg +# tool (see https://github.com/dawbarton/pdf2svg) or inkscape (see +# https://inkscape.org) to generate formulas as SVG images instead of PNGs for +# the HTML output. These images will generally look nicer at scaled resolutions. +# Possible values are: png (the default) and svg (looks nicer but requires the +# pdf2svg or inkscape tool). +# The default value is: png. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_FORMULA_FORMAT = png + +# Use this tag to change the font size of LaTeX formulas included as images in +# the HTML documentation. When you change the font size after a successful +# Doxygen run you need to manually remove any form_*.png images from the HTML +# output directory to force them to be regenerated. +# Minimum value: 8, maximum value: 50, default value: 10. +# This tag requires that the tag GENERATE_HTML is set to YES. + +FORMULA_FONTSIZE = 10 + +# The FORMULA_MACROFILE can contain LaTeX \newcommand and \renewcommand commands +# to create new LaTeX commands to be used in formulas as building blocks. See +# the section "Including formulas" for details. + +FORMULA_MACROFILE = + +# Enable the USE_MATHJAX option to render LaTeX formulas using MathJax (see +# https://www.mathjax.org) which uses client side JavaScript for the rendering +# instead of using pre-rendered bitmaps. Use this if you do not have LaTeX +# installed or if you want to formulas look prettier in the HTML output. When +# enabled you may also need to install MathJax separately and configure the path +# to it using the MATHJAX_RELPATH option. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +USE_MATHJAX = NO + +# With MATHJAX_VERSION it is possible to specify the MathJax version to be used. +# Note that the different versions of MathJax have different requirements with +# regards to the different settings, so it is possible that also other MathJax +# settings have to be changed when switching between the different MathJax +# versions. +# Possible values are: MathJax_2 and MathJax_3. +# The default value is: MathJax_2. +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_VERSION = MathJax_2 + +# When MathJax is enabled you can set the default output format to be used for +# the MathJax output. For more details about the output format see MathJax +# version 2 (see: +# http://docs.mathjax.org/en/v2.7-latest/output.html) and MathJax version 3 +# (see: +# http://docs.mathjax.org/en/latest/web/components/output.html). +# Possible values are: HTML-CSS (which is slower, but has the best +# compatibility. This is the name for Mathjax version 2, for MathJax version 3 +# this will be translated into chtml), NativeMML (i.e. MathML. Only supported +# for MathJax 2. For MathJax version 3 chtml will be used instead.), chtml (This +# is the name for Mathjax version 3, for MathJax version 2 this will be +# translated into HTML-CSS) and SVG. +# The default value is: HTML-CSS. +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_FORMAT = HTML-CSS + +# When MathJax is enabled you need to specify the location relative to the HTML +# output directory using the MATHJAX_RELPATH option. The destination directory +# should contain the MathJax.js script. For instance, if the mathjax directory +# is located at the same level as the HTML output directory, then +# MATHJAX_RELPATH should be ../mathjax. The default value points to the MathJax +# Content Delivery Network so you can quickly see the result without installing +# MathJax. However, it is strongly recommended to install a local copy of +# MathJax from https://www.mathjax.org before deployment. The default value is: +# - in case of MathJax version 2: https://cdn.jsdelivr.net/npm/mathjax@2 +# - in case of MathJax version 3: https://cdn.jsdelivr.net/npm/mathjax@3 +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_RELPATH = + +# The MATHJAX_EXTENSIONS tag can be used to specify one or more MathJax +# extension names that should be enabled during MathJax rendering. For example +# for MathJax version 2 (see +# https://docs.mathjax.org/en/v2.7-latest/tex.html#tex-and-latex-extensions): +# MATHJAX_EXTENSIONS = TeX/AMSmath TeX/AMSsymbols +# For example for MathJax version 3 (see +# http://docs.mathjax.org/en/latest/input/tex/extensions/index.html): +# MATHJAX_EXTENSIONS = ams +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_EXTENSIONS = + +# The MATHJAX_CODEFILE tag can be used to specify a file with JavaScript pieces +# of code that will be used on startup of the MathJax code. See the MathJax site +# (see: +# http://docs.mathjax.org/en/v2.7-latest/output.html) for more details. For an +# example see the documentation. +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_CODEFILE = + +# When the SEARCHENGINE tag is enabled Doxygen will generate a search box for +# the HTML output. The underlying search engine uses JavaScript and DHTML and +# should work on any modern browser. Note that when using HTML help +# (GENERATE_HTMLHELP), Qt help (GENERATE_QHP), or docsets (GENERATE_DOCSET) +# there is already a search function so this one should typically be disabled. +# For large projects the JavaScript based search engine can be slow, then +# enabling SERVER_BASED_SEARCH may provide a better solution. It is possible to +# search using the keyboard; to jump to the search box use + S +# (what the is depends on the OS and browser, but it is typically +# , /