diff --git a/src/ibis_birdbrain/__init__.py b/src/ibis_birdbrain/__init__.py new file mode 100644 index 0000000..0dd3e37 --- /dev/null +++ b/src/ibis_birdbrain/__init__.py @@ -0,0 +1,6 @@ +# imports + +# exports +from ibis_birdbrain.bot import Bot + +__all__ = ["Bot"] diff --git a/src/ibis_birdbrain/__main__.py b/src/ibis_birdbrain/__main__.py new file mode 100644 index 0000000..580f22a --- /dev/null +++ b/src/ibis_birdbrain/__main__.py @@ -0,0 +1,3 @@ +from ibis_birdbrain.cli import app + +app(prog_name="birdbrain") diff --git a/src/ibis_birdbrain/attachments/__init__.py b/src/ibis_birdbrain/attachments/__init__.py new file mode 100644 index 0000000..fab0592 --- /dev/null +++ b/src/ibis_birdbrain/attachments/__init__.py @@ -0,0 +1,114 @@ +"""Ibis Birdbrain attachments.""" + +# imports +from uuid import uuid4 +from typing import Any +from datetime import datetime + +from ibis.expr.types.relations import Table + + +# classes +class Attachment: + """Ibis Birdbrain attachment.""" + + content: Any + id: str + created_at: datetime + name: str | None + description: str | None + + def __init__( + self, + content, + name=None, + description=None, + ): + self.id = str(uuid4()) + self.created_at = datetime.now() + + self.name = name + self.description = description + self.content = content + + def encode(self) -> Table: + ... + + def decode(self, t: Table) -> str: + ... + + def open(self) -> Any: + return self.content + + def __str__(self): + return f"""{self.__class__.__name__} + **guid**: {self.id} + **time**: {self.created_at} + **name**: {self.name} + **desc**: {self.description}""" + + def __repr__(self): + return str(self) + + +class Attachments: + """Ibis Birdbrain attachments.""" + + attachments: dict[str, Attachment] + + def __init__(self, attachments: list[Attachment] = []) -> None: + """Initialize the attachments.""" + self.attachments = {a.id: a for a in attachments} + + def add_attachment(self, attachment: Attachment): + """Add an attachment to the collection.""" + self.attachments[attachment.id] = attachment + + def append(self, attachment: Attachment): + """Alias for add_attachment.""" + self.add_attachment(attachment) + + def __getitem__(self, id: str | int): + """Get an attachment from the collection.""" + if isinstance(id, int): + return list(self.attachments.values())[id] + return self.attachments[id] + + def __setitem__(self, id: str, attachment: Attachment): + """Set an attachment in the collection.""" + self.attachments[id] = attachment + + def __len__(self) -> int: + """Get the length of the collection.""" + return len(self.attachments) + + def __iter__(self): + """Iterate over the collection.""" + return iter(self.attachments.keys()) + + def __str__(self): + return "\n\n".join([str(a) for a in self.attachments.values()]) + + def __repr__(self): + return str(self) + + +# exports +from ibis_birdbrain.attachments.viz import ChartAttachment +from ibis_birdbrain.attachments.data import DataAttachment, TableAttachment +from ibis_birdbrain.attachments.text import ( + TextAttachment, + CodeAttachment, + WebpageAttachment, +) + +__all__ = [ + "Attachment", + "Attachments", + "DataAttachment", + "TableAttachment", + "ChartAttachment", + "TextAttachment", + "CodeAttachment", + "WebpageAttachment", +] diff --git a/src/ibis_birdbrain/attachments/data.py b/src/ibis_birdbrain/attachments/data.py new file mode 100644 index 0000000..b0d2748 --- /dev/null +++ b/src/ibis_birdbrain/attachments/data.py @@ -0,0 +1,77 @@ +# imports +import ibis + +from ibis.backends.base import BaseBackend +from ibis.expr.types.relations import Table + +from ibis_birdbrain.attachments import Attachment + +# configure Ibis +ibis.options.interactive = True +ibis.options.repr.interactive.max_rows = 10 +ibis.options.repr.interactive.max_columns = 20 +ibis.options.repr.interactive.max_length = 20 + + +# classes +class DataAttachment(Attachment): + """A database attachment.""" + + content: BaseBackend + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.con = self.content # alias + if self.name is None: + try: + self.name = ( + self.content.current_database + "." + self.content.current_schema + ) + except: + self.name = "unknown" + + try: + self.sql_dialect = self.content.name + except: + self.sql_dialect = "unknown" + try: + self.description = "tables:\n\t" + "\n\t".join( + [t for t in self.content.list_tables()] + ) + except: + self.description = "empty database\n" + + def __str__(self): + return ( + super().__str__() + + f""" + **dialect**: {self.sql_dialect}""" + ) + + +class TableAttachment(Attachment): + """A table attachment.""" + + content: Table + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + try: + self.name = self.content.get_name() + except AttributeError: + self.name = None + self.schema = self.content.schema() + self.description = "\n" + str(self.schema) + + def encode(self) -> Table: + ... + + def decode(self, t: Table) -> str: + ... + + def __str__(self): + return ( + super().__str__() + + f""" + **table**:\n{self.content}""" + ) diff --git a/src/ibis_birdbrain/attachments/text.py b/src/ibis_birdbrain/attachments/text.py new file mode 100644 index 0000000..c3937b3 --- /dev/null +++ b/src/ibis_birdbrain/attachments/text.py @@ -0,0 +1,99 @@ +# imports +from ibis_birdbrain.utils.web import webpage_to_str, open_browser +from ibis_birdbrain.utils.strings import estimate_tokens, shorten_str +from ibis_birdbrain.attachments import Attachment + + +# classes +class TextAttachment(Attachment): + """A text attachment.""" + + content: str + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if estimate_tokens(self.content) > 200: + self.display_content = ( + shorten_str(self.content, 50) + + shorten_str(self.content[::-1], 50)[::-1] + ) + else: + self.display_content = self.content + + def encode(self): + ... + + def decode(self): + ... + + def __str__(self): + return ( + super().__str__() + + f""" + **text**:\n{self.content}""" + ) + + +class WebpageAttachment(Attachment): + """A webpage attachment.""" + + content: str + url: str + + def __init__(self, *args, url="https://ibis-project.org", **kwargs): + super().__init__(*args, **kwargs) + self.url = url + if self.content is None: + self.content = webpage_to_str(self.url) + if estimate_tokens(self.content) > 100: + self.display_content = ( + shorten_str(self.content, 50) + + shorten_str(self.content[::-1], 50)[::-1] + ) + else: + self.display_content = self.content + + def encode(self): + ... + + def decode(self): + ... + + def __str__(self): + return ( + super().__str__() + + f""" + **url**: {self.url} + **content**:\n{self.display_content}""" + ) + + def open(self, browser=False): + if browser: + open_browser(self.url) + else: + return self.url + + +class CodeAttachment(TextAttachment): + """A code attachment.""" + + content: str + language: str + + def __init__(self, language="python", *args, **kwargs): + super().__init__(*args, **kwargs) + self.language = language + + def encode(self): + ... + + def decode(self): + ... + + def __str__(self): + return ( + super().__str__() + + f""" + **language**: {self.language} + **code**:\n{self.content}""" + ) diff --git a/src/ibis_birdbrain/attachments/viz.py b/src/ibis_birdbrain/attachments/viz.py new file mode 100644 index 0000000..46a4f2f --- /dev/null +++ b/src/ibis_birdbrain/attachments/viz.py @@ -0,0 +1,27 @@ +# imports +from plotly.graph_objs import Figure + +from ibis_birdbrain.attachments import Attachment + + +# classes +class ChartAttachment(Attachment): + """A chart attachment.""" + + content: Figure + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def encode(self): + ... + + def decode(self): + ... + + def __str__(self): + return ( + super().__str__() + + f""" + **chart**:\n{self.content}""" + ) diff --git a/src/ibis_birdbrain/bot.py b/src/ibis_birdbrain/bot.py new file mode 100644 index 0000000..0f1a842 --- /dev/null +++ b/src/ibis_birdbrain/bot.py @@ -0,0 +1,69 @@ +"""Ibis Birdbrain bot. + +A bot can be called to perform tasks on behalf of a user, in turn calling +its available tools (including other bots) to perform those tasks. +""" + +# imports +import ibis + +from uuid import uuid4 +from typing import Any +from datetime import datetime + +from ibis.backends.base import BaseBackend + +from ibis_birdbrain.logging import log +from ibis_birdbrain.messages import Message +from ibis_birdbrain.utils.messages import to_message + + +# classes +class Bot: + """Ibis Birdbrain bot.""" + + id: str + created_at: datetime + + data: dict[str, BaseBackend] + name: str + user_name: str + description: str + version: str + + current_subject: str + + def __init__( + self, + data: dict[str, str] = { + "system": "duckdb://birdbrain.ddb", + "memory": "duckdb://", + }, + name="birdbrain", + user_name="user", + description="Ibis Birdbrain bot.", + version="infinity", + ) -> None: + """Initialize the bot.""" + self.id = uuid4() + self.created_at = datetime.now() + + self.data = {k: ibis.connect(v) for k, v in data.items()} + self.name = name + self.user_name = user_name + self.description = description + self.version = version + + self.current_subject = "" + + def __call__( + self, + text: str = "Who are you and what can you do?", + stuff: list[Any] = [], + ) -> Message: + """Call upon the bot.""" + + log.info(f"Bot {self.name} called with text: {text}") + im = to_message(text, stuff) + + return im diff --git a/src/ibis_birdbrain/cli.py b/src/ibis_birdbrain/cli.py new file mode 100644 index 0000000..ef0225a --- /dev/null +++ b/src/ibis_birdbrain/cli.py @@ -0,0 +1,41 @@ +""" +Ibis Birdbrain CLI. +""" + +# imports +import typer + +from typing_extensions import Annotated, Optional + +from ibis_birdbrain.commands import ipy_run, testing_run + +# typer config +app = typer.Typer(no_args_is_help=True) + + +# subcommands +@app.command() +def ipy(): + """ + ipy + """ + ipy_run() + + +@app.command() +def test(): + """ + test + """ + testing_run() + + +# main +@app.callback() +def cli(): + return + + +## main +if __name__ == "__main__": + typer.run(cli) diff --git a/src/ibis_birdbrain/commands/__init__.py b/src/ibis_birdbrain/commands/__init__.py new file mode 100644 index 0000000..203d9a8 --- /dev/null +++ b/src/ibis_birdbrain/commands/__init__.py @@ -0,0 +1,7 @@ +# imports + +# exports +from ibis_birdbrain.commands.ipy import ipy_run +from ibis_birdbrain.commands.testing import testing_run + +__all__ = ["ipy_run", "testing_run"] diff --git a/src/ibis_birdbrain/commands/ipy.py b/src/ibis_birdbrain/commands/ipy.py new file mode 100644 index 0000000..94f0cca --- /dev/null +++ b/src/ibis_birdbrain/commands/ipy.py @@ -0,0 +1,17 @@ +def ipy_run(interactive=False): + # imports + import ibis + import IPython + + from ibis_birdbrain import Bot + + # config + ibis.options.interactive = True + ibis.options.repr.interactive.max_rows = 20 + ibis.options.repr.interactive.max_columns = 20 + + # setup bot + bot = Bot() + + # start IPython + IPython.embed(colors="neutral") diff --git a/src/ibis_birdbrain/commands/testing.py b/src/ibis_birdbrain/commands/testing.py new file mode 100644 index 0000000..42831d9 --- /dev/null +++ b/src/ibis_birdbrain/commands/testing.py @@ -0,0 +1,6 @@ +def testing_run(): + from rich.console import Console + + console = Console() + console.print(f"testing: ", style="bold violet", end="") + console.print(f"done...") diff --git a/src/ibis_birdbrain/logging/__init__.py b/src/ibis_birdbrain/logging/__init__.py new file mode 100644 index 0000000..d21c3ee --- /dev/null +++ b/src/ibis_birdbrain/logging/__init__.py @@ -0,0 +1,8 @@ +# imports +import logging as log + +# config +log.basicConfig(level=log.INFO) + +# exports +__all__ = ["log"] diff --git a/src/ibis_birdbrain/messages/__init__.py b/src/ibis_birdbrain/messages/__init__.py new file mode 100644 index 0000000..5fe18ff --- /dev/null +++ b/src/ibis_birdbrain/messages/__init__.py @@ -0,0 +1,120 @@ +"""Ibis Birdbrain messages.""" + +# imports +from uuid import uuid4 +from datetime import datetime + +from ibis.expr.types.relations import Table + +from ibis_birdbrain.attachments import Attachment, Attachments + + +# classes +class Message: + """Ibis Birdbrain message.""" + + id: str + created_at: datetime + to_address: str + from_address: str + subject: str + body: str + attachments: Attachments + + def __init__( + self, + to_address="", + from_address="", + subject="", + body="", + attachments: Attachments | list[Attachment] = [], + ) -> None: + """Initialize the message.""" + self.id = str(uuid4()) + self.created_at = datetime.now() + + self.to_address = to_address + self.from_address = from_address + self.subject = subject + self.body = body + + # TODO: feels a little hacky + if isinstance(attachments, Attachments): + self.attachments = attachments + else: + self.attachments = Attachments(attachments=attachments) + + def encode(self) -> Table: + ... + + def decode(self, t: Table) -> str: + ... + + def add_attachment(self, attachment: Attachment): + """Add an attachment to the email.""" + self.attachments.append(attachment) + + def append(self, attachment: Attachment): + """Alias for add_attachment.""" + self.add_attachment(attachment) + + def __str__(self): + return f"{self.__class__.__name__}({self.id})" + + def __repr__(self): + return str(self) + + +class Messages: + """Ibis Birdbrain messages.""" + + messages: dict[str, Message] + + def __init__( + self, + messages: list[Message] = [], + ) -> None: + """Initialize the messages.""" + self.messages = {m.id: m for m in messages} + + def add_message(self, message: Message): + """Add a message to the collection.""" + self.messages[message.id] = message + + def append(self, message: Message): + """Alias for add_message.""" + self.add_message(message) + + def __getitem__(self, id: str | int): + """Get a message from the collection.""" + if isinstance(id, int): + return list(self.messages.values())[id] + return self.messages[id] + + def __setitem__(self, id: str, message: Message): + """Set a message in the collection.""" + self.messages[id] = message + + def __len__(self) -> int: + """Get the length of the collection.""" + return len(self.messages) + + def __iter__(self): + """Iterate over the collection.""" + return iter(self.messages.keys()) + + def __str__(self): + return f"---\n".join([str(m) for m in self.messages.values()]) + + def __repr__(self): + return str(self) + + def attachments(self) -> list[str]: + """Get the list of attachment GUIDs from the messages.""" + return list(set([a for m in self.messages.values() for a in m.attachments])) + + +# exports +from ibis_birdbrain.messages.email import Email + +__all__ = ["Message", "Messages", "Email"] diff --git a/src/ibis_birdbrain/messages/email.py b/src/ibis_birdbrain/messages/email.py new file mode 100644 index 0000000..3c9b228 --- /dev/null +++ b/src/ibis_birdbrain/messages/email.py @@ -0,0 +1,29 @@ +""" +Emails in Ibis Birdbrain are currently the only +implementation of a Message, providing an email-like +string representation for simplicity. +""" + +# imports +from ibis_birdbrain.messages import Message + + +# classes +class Email(Message): + """An email.""" + + def __str__(self): + return f"""To: {self.to_address} +From: {self.from_address} +Subject: {self.subject} +Sent at: {self.created_at} +Message: {self.id} + +{self.body} + +Attachments: + +{self.attachments}\n""" + + def __repr__(self): + return str(self) diff --git a/src/ibis_birdbrain/utils/attachments.py b/src/ibis_birdbrain/utils/attachments.py new file mode 100644 index 0000000..1a32875 --- /dev/null +++ b/src/ibis_birdbrain/utils/attachments.py @@ -0,0 +1,36 @@ +# imports +from typing import Any + +from plotly.graph_objs import Figure + +from ibis.expr.types import Table +from ibis.backends.base import BaseBackend + +from ibis_birdbrain.attachments import ( + Attachment, + TextAttachment, + DataAttachment, + TableAttachment, + ChartAttachment, + WebpageAttachment, +) + + +# functions +def to_attachment(thing: Any) -> Attachment | None: + """Converts a thing to an attachment.""" + if isinstance(thing, Attachment): + return thing + elif isinstance(thing, str): + if thing.startswith("http"): + return WebpageAttachment(thing) + else: + return TextAttachment(thing) + elif isinstance(thing, BaseBackend): + return DataAttachment(thing) + elif isinstance(thing, Table): + return TableAttachment(thing) + elif isinstance(thing, Figure): + return ChartAttachment(thing) + + return None diff --git a/src/ibis_birdbrain/utils/messages.py b/src/ibis_birdbrain/utils/messages.py new file mode 100644 index 0000000..ec37858 --- /dev/null +++ b/src/ibis_birdbrain/utils/messages.py @@ -0,0 +1,17 @@ +# imports +from typing import Any +from ibis_birdbrain.messages import Message, Email + +from ibis_birdbrain.utils.attachments import to_attachment + + +# functions +def to_message(text: str, stuff: list[Any] = []) -> Message: + """Convert text and stuff into a message with attachments.""" + attachments = [] + for thing in stuff: + attachment = to_attachment(thing) + if attachment is not None: + attachments.append(attachment) + + return Email(body=text, attachments=attachments) diff --git a/src/ibis_birdbrain/utils/strings.py b/src/ibis_birdbrain/utils/strings.py new file mode 100644 index 0000000..559eb1f --- /dev/null +++ b/src/ibis_birdbrain/utils/strings.py @@ -0,0 +1,33 @@ +# imports + + +# functions +def estimate_tokens(s: str) -> int: + """Estimates the number of tokens in a string.""" + + return len(s) // 4 + + +def shorten_str(s: str, max_len: int = 27) -> str: + """Converts a string to a display string.""" + + if len(s) > max_len: + return f"{s[:max_len]}..." + else: + return s + + +def str_to_list_of_str(s: str, max_chunk_len: int = 1000, sep: str = "\n") -> list[str]: + """Splits a string into a list of strings.""" + + result = [] + + # TODO: better string chunking algorithm + # split the string into chunks + chunks = [s[i : i + max_chunk_len] for i in range(0, len(s), max_chunk_len)] + + # split the chunks into lines + for chunk in chunks: + result.extend(chunk.split(sep)) + + return result diff --git a/src/ibis_birdbrain/utils/web.py b/src/ibis_birdbrain/utils/web.py new file mode 100644 index 0000000..904e165 --- /dev/null +++ b/src/ibis_birdbrain/utils/web.py @@ -0,0 +1,37 @@ +# imports +import requests +import webbrowser + + +from itertools import islice +from html2text import html2text +from duckduckgo_search import DDGS + + +# functions +def open_browser(url: str) -> str: + """Opens the URL in a web browser.""" + try: + webbrowser.open(url.strip("/")) + except Exception as e: + return str(e) + + return f"Opened {url} in web browser." + + +def search_internet(query: str, n_results: int = 10) -> list[dict]: + """Searches the internet for n results.""" + ddgs = DDGS() + return [r for r in islice(ddgs.text(query, backend="lite"), n_results)] + + +def webpage_to_str(url: str = "https://ibis-project.org") -> str: + """Reads a webpage link into a string.""" + response = requests.get(url) + return ( + html2text(response.text) + # .replace("\n", " ") + # .replace("\r", " ") + # .replace("\t", " ") + # .replace(" ", " ") + )