From 3d1c3396a64dffca799678eabb2b03463273716e Mon Sep 17 00:00:00 2001 From: doramasma Date: Wed, 15 Jan 2025 19:54:04 +0000 Subject: [PATCH 1/4] Added baseline UI using textual --- pyproject.toml | 3 +- src/news_ask_ai/__init__.py | 21 +------ src/news_ask_ai/ask/news_ask_ui.py | 81 +++++++++++++++++++++++++++ src/news_ask_ai/static/css/styles.css | 66 ++++++++++++++++++++++ uv.lock | 67 ++++++++++++++++++++++ 5 files changed, 219 insertions(+), 19 deletions(-) create mode 100644 src/news_ask_ai/ask/news_ask_ui.py create mode 100644 src/news_ask_ai/static/css/styles.css diff --git a/pyproject.toml b/pyproject.toml index 80df133..00d78ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,8 @@ dependencies = [ "sentence-transformers>=3.3.1", "transformers>=4.48.0", "accelerate>=1.2.1", - "gnews>=0.3.9" + "gnews>=0.3.9", + "textual>=1.0.0" ] [project.optional-dependencies] diff --git a/src/news_ask_ai/__init__.py b/src/news_ask_ai/__init__.py index eb0391e..8d3ed37 100644 --- a/src/news_ask_ai/__init__.py +++ b/src/news_ask_ai/__init__.py @@ -1,5 +1,4 @@ -from news_ask_ai.ask.news_ask_engine import NewsAskEngine - +from news_ask_ai.ask.news_ask_ui import NewsAskUI from news_ask_ai.utils.logger import setup_logger logger = setup_logger() @@ -7,19 +6,5 @@ def main() -> None: logger.info("Initializing RAG engine...") - search_engine = NewsAskEngine(collection_name="news-collection") - - print("Indicate the topic of the news you want to ask about") - topic_input = input("\nYour topic: ") - search_engine.ingest_data(topic_input) - - while True: - print("What do you want to ask about?") - question_input = input("\nYour question: ") - if question_input.lower() == "exit": - break - - completion = search_engine.get_completions(question_input) - print(completion) - - print("Thank you for using the NewsAskAI system. Goodbye!") + app = NewsAskUI() + app.run() diff --git a/src/news_ask_ai/ask/news_ask_ui.py b/src/news_ask_ai/ask/news_ask_ui.py new file mode 100644 index 0000000..32f8f64 --- /dev/null +++ b/src/news_ask_ai/ask/news_ask_ui.py @@ -0,0 +1,81 @@ +from textual.app import App, ComposeResult +from textual.widgets import Footer, Header, Input, Button, Static +from textual.containers import Horizontal, Container + +from textual.widget import Widget + + +class MessageBox(Widget): + def __init__(self, text: str, role: str) -> None: + self.text = text + self.role = role + super().__init__() + + def compose(self) -> ComposeResult: + yield Static(self.text, classes=f"message {self.role}") + + +class NewsAskUI(App): # type: ignore + TITLE = "NewsAskAI" + SUB_TITLE = "Ask questions about the news directly in your terminal" + CSS_PATH = "../static/css/styles.css" + + def compose(self) -> ComposeResult: + yield Header() + + # -- Container for topic input and ingest button. + with Container(id="news_topic_container"): + yield MessageBox( + "Welcome to NewsAskAI!\n" + "Get the latest news insights with just a few clicks.\n" + "Enter a topic of interest below and press 'Ingest news' to start.\n" + "Need help? Use the commands at the bottom of your screen.", + role="info", + ) + + with Horizontal(id="input_box"): + yield Input(placeholder="Enter news topic...", id="news_topic_input") + yield Button(label="Ingest news", variant="success", id="news_ingest_button") + + # -- Container for conversation UI, hidden by default. + with Container(id="conversation_container"): + yield MessageBox( + "You're all set to explore the news!\n" + "Type a question about the ingested news and press 'Ask' to get your answer.\n" + "Wait for the response...\n" + "Need assistance? Commands are listed at the bottom.", + role="info", + ) + + with Horizontal(id="input_box"): + yield Input(placeholder="Enter your question", id="conversation_input") + yield Button(label="Ask", variant="success", id="conversation_button") + + yield Footer() + + def on_mount(self) -> None: + """Called when app is first mounted.""" + self.query_one("#news_topic_input", Input).focus() + + conversation_container = self.query_one("#conversation_container") + conversation_container.display = False + + async def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button pressed events.""" + button = event.button + + if button.id == "news_ingest_button": + news_topic_input = self.query_one("#news_topic_input", Input) + if not news_topic_input.value.strip(): + return # No topic entered, do nothing or prompt us + + conversation_container = self.query_one("#news_topic_container") + conversation_container.display = False # remove 'none', showing it + + conversation_container = self.query_one("#conversation_container") + conversation_container.display = True # remove 'none', showing it + + # TODO: Ingestion routine + + elif button.id == "send_button": + pass diff --git a/src/news_ask_ai/static/css/styles.css b/src/news_ask_ai/static/css/styles.css new file mode 100644 index 0000000..e07f318 --- /dev/null +++ b/src/news_ask_ai/static/css/styles.css @@ -0,0 +1,66 @@ +Screen { + background: #212529; +} + +MessageBox { + layout: horizontal; + height: auto; + align-horizontal: center; +} + +.message { + width: auto; + min-width: 25%; + border: tall black; + padding: 1 3; + margin: 1 0; + background: #343a40; +} + +.info { + width: auto; + text-align: center; +} + +.question { + margin: 1 25 1 0; +} + +.answer { + margin: 1 0 1 25; +} + + + +#input_box { + dock: bottom; + height: auto; + width: 100%; + margin: 0 0 2 0; + align_horizontal: center; +} + +/* News style */ + +#news_topic_input { + width: 50%; + background: #343a40; +} + +#news_ingest_button { + width: auto; +} + +/* Conversation style */ + +#conversation_input { + width: 50%; + background: #343a40; +} + + +#conversation_button { + width: auto; +} + + diff --git a/uv.lock b/uv.lock index 015468c..2b35759 100644 --- a/uv.lock +++ b/uv.lock @@ -594,6 +594,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/a8/17f5e28cecdbd6d48127c22abdb794740803491f422a11905c4569d8e139/kubernetes-31.0.0-py2.py3-none-any.whl", hash = "sha256:bf141e2d380c8520eada8b351f4e319ffee9636328c137aa432bc486ca1200e1", size = 1857013 }, ] +[[package]] +name = "linkify-it-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820 }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -606,6 +618,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] +plugins = [ + { name = "mdit-py-plugins" }, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -644,6 +664,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316 }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -765,6 +797,7 @@ dependencies = [ { name = "mypy" }, { name = "ruff" }, { name = "sentence-transformers" }, + { name = "textual" }, { name = "transformers" }, ] @@ -782,6 +815,7 @@ requires-dist = [ { name = "mypy", specifier = ">=1.14.1" }, { name = "ruff", specifier = ">=0.8.6" }, { name = "sentence-transformers", specifier = ">=3.3.1" }, + { name = "textual", specifier = ">=1.0.0" }, { name = "transformers", specifier = ">=4.48.0" }, ] @@ -1202,6 +1236,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/6c/41c21c6c8af92b9fea313aa47c75de49e2f9a467964ee33eb0135d47eb64/pillow-11.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756", size = 2377651 }, ] +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + [[package]] name = "posthog" version = "3.7.5" @@ -1703,6 +1746,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/cb/b86984bed139586d01532a587464b5805f12e397594f19f931c4c2fbfa61/tenacity-9.0.0-py3-none-any.whl", hash = "sha256:93de0c98785b27fcf659856aa9f54bfbd399e29969b0621bc7f762bd441b4539", size = 28169 }, ] +[[package]] +name = "textual" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify", "plugins"] }, + { name = "platformdirs" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/b6/59b1de04bb4dca0f21ed7ba0b19309ed7f3f5de4396edf20cc2855e53085/textual-1.0.0.tar.gz", hash = "sha256:bec9fe63547c1c552569d1b75d309038b7d456c03f86dfa3706ddb099b151399", size = 1532733 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/bb/5fb6656c625019cd653d5215237d7cd6e0b12e7eae4195c3d1c91b2136fc/textual-1.0.0-py3-none-any.whl", hash = "sha256:2d4a701781c05104925e463ae370c630567c70c2880e92ab838052e3e23c986f", size = 660456 }, +] + [[package]] name = "threadpoolctl" version = "3.5.0" @@ -1839,6 +1897,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] +[[package]] +name = "uc-micro-py" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229 }, +] + [[package]] name = "urllib3" version = "2.3.0" From 5cb6679abad397bc3000fc1898824490449a0b0e Mon Sep 17 00:00:00 2001 From: doramasma Date: Wed, 15 Jan 2025 21:12:04 +0000 Subject: [PATCH 2/4] Defined conversation routine and UI improvements --- src/news_ask_ai/ask/news_ask_ui.py | 57 +++++++++++++++++++++------ src/news_ask_ai/static/css/styles.css | 12 ++++-- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/src/news_ask_ai/ask/news_ask_ui.py b/src/news_ask_ai/ask/news_ask_ui.py index 32f8f64..21d8cf5 100644 --- a/src/news_ask_ai/ask/news_ask_ui.py +++ b/src/news_ask_ai/ask/news_ask_ui.py @@ -4,6 +4,15 @@ from textual.widget import Widget +# from news_ask_ai.ask.news_ask_engine import NewsAskEngine +from news_ask_ai.utils.logger import setup_logger + +logger = setup_logger() + + +class ConversationalContainer(Container, can_focus=True): + """Conversational container widget.""" + class MessageBox(Widget): def __init__(self, text: str, role: str) -> None: @@ -20,6 +29,10 @@ class NewsAskUI(App): # type: ignore SUB_TITLE = "Ask questions about the news directly in your terminal" CSS_PATH = "../static/css/styles.css" + def __init__(self) -> None: + super().__init__() + logger.info("Initializing RAG engine...") + def compose(self) -> ComposeResult: yield Header() @@ -39,17 +52,18 @@ def compose(self) -> ComposeResult: # -- Container for conversation UI, hidden by default. with Container(id="conversation_container"): - yield MessageBox( - "You're all set to explore the news!\n" - "Type a question about the ingested news and press 'Ask' to get your answer.\n" - "Wait for the response...\n" - "Need assistance? Commands are listed at the bottom.", - role="info", - ) - - with Horizontal(id="input_box"): - yield Input(placeholder="Enter your question", id="conversation_input") - yield Button(label="Ask", variant="success", id="conversation_button") + with ConversationalContainer(id="conversation_box"): + yield MessageBox( + "You're all set to explore the news!\n" + "Type a question about the ingested news and press 'Ask' to get your answer.\n" + "Wait for the response...\n" + "Need assistance? Commands are listed at the bottom.", + role="info", + ) + + with Horizontal(id="input_box"): + yield Input(placeholder="Enter your question", id="conversation_input") + yield Button(label="Ask", variant="success", id="conversation_button") yield Footer() @@ -77,5 +91,22 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: # TODO: Ingestion routine - elif button.id == "send_button": - pass + elif button.id == "conversation_button": + await self.conversation() + + async def conversation(self) -> None: + message_input = self.query_one("#conversation_input", Input) + if message_input.value == "": + return + + conversation_box = self.query_one("#conversation_box") + + message_box = MessageBox(message_input.value, "question") + conversation_box.mount(message_box) + conversation_box.scroll_end(animate=True) + + # Clean up the input without triggering events + with message_input.prevent(Input.Changed): + message_input.value = "" + + conversation_box.mount(MessageBox("Test", "test")) diff --git a/src/news_ask_ai/static/css/styles.css b/src/news_ask_ai/static/css/styles.css index e07f318..87b162e 100644 --- a/src/news_ask_ai/static/css/styles.css +++ b/src/news_ask_ai/static/css/styles.css @@ -9,8 +9,9 @@ MessageBox { } .message { - width: auto; + width: 50%; min-width: 25%; + max-width: 50%; border: tall black; padding: 1 3; margin: 1 0; @@ -23,14 +24,17 @@ MessageBox { } .question { - margin: 1 25 1 0; + margin: 1 0 1 25; } .answer { - margin: 1 0 1 25; + margin: 1 25 1 0; } - +#conversation_box { + overflow-y: auto; + height: 100%; +} #input_box { dock: bottom; From e33b8c6899241093716818397e4b83147c98d326 Mon Sep 17 00:00:00 2001 From: doramasma Date: Wed, 15 Jan 2025 21:49:59 +0000 Subject: [PATCH 3/4] Integrate NewsAskEngine into the UI workflow --- src/news_ask_ai/ask/news_ask_ui.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/news_ask_ai/ask/news_ask_ui.py b/src/news_ask_ai/ask/news_ask_ui.py index 21d8cf5..0ad2e51 100644 --- a/src/news_ask_ai/ask/news_ask_ui.py +++ b/src/news_ask_ai/ask/news_ask_ui.py @@ -4,7 +4,7 @@ from textual.widget import Widget -# from news_ask_ai.ask.news_ask_engine import NewsAskEngine +from news_ask_ai.ask.news_ask_engine import NewsAskEngine from news_ask_ai.utils.logger import setup_logger logger = setup_logger() @@ -32,6 +32,7 @@ class NewsAskUI(App): # type: ignore def __init__(self) -> None: super().__init__() logger.info("Initializing RAG engine...") + self.search_engine = NewsAskEngine(collection_name="news-collection") def compose(self) -> ComposeResult: yield Header() @@ -81,15 +82,15 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: if button.id == "news_ingest_button": news_topic_input = self.query_one("#news_topic_input", Input) if not news_topic_input.value.strip(): - return # No topic entered, do nothing or prompt us + return conversation_container = self.query_one("#news_topic_container") - conversation_container.display = False # remove 'none', showing it + conversation_container.display = False conversation_container = self.query_one("#conversation_container") - conversation_container.display = True # remove 'none', showing it + conversation_container.display = True - # TODO: Ingestion routine + self.search_engine.ingest_data(news_topic_input.value) elif button.id == "conversation_button": await self.conversation() @@ -109,4 +110,11 @@ async def conversation(self) -> None: with message_input.prevent(Input.Changed): message_input.value = "" - conversation_box.mount(MessageBox("Test", "test")) + completion = self.search_engine.get_completions(message_input.value) + + conversation_box.mount( + MessageBox( + text=completion, + role="answer", + ) + ) From af40549a90dab24bbe32739ac09729f12e376686 Mon Sep 17 00:00:00 2001 From: doramasma Date: Thu, 16 Jan 2025 17:10:38 +0000 Subject: [PATCH 4/4] Modified setup logger and some hotfixes --- src/news_ask_ai/ask/news_ask_ui.py | 2 +- .../services/llm_completion_service.py | 5 +++++ src/news_ask_ai/utils/logger.py | 17 ++++++++--------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/news_ask_ai/ask/news_ask_ui.py b/src/news_ask_ai/ask/news_ask_ui.py index 0ad2e51..524be00 100644 --- a/src/news_ask_ai/ask/news_ask_ui.py +++ b/src/news_ask_ai/ask/news_ask_ui.py @@ -110,7 +110,7 @@ async def conversation(self) -> None: with message_input.prevent(Input.Changed): message_input.value = "" - completion = self.search_engine.get_completions(message_input.value) + completion = self.search_engine.get_completions(message_box.text) conversation_box.mount( MessageBox( diff --git a/src/news_ask_ai/services/llm_completion_service.py b/src/news_ask_ai/services/llm_completion_service.py index 33bd865..b2aac77 100644 --- a/src/news_ask_ai/services/llm_completion_service.py +++ b/src/news_ask_ai/services/llm_completion_service.py @@ -1,3 +1,4 @@ +import logging from typing import cast import torch @@ -11,6 +12,9 @@ logger = setup_logger() +logging.getLogger("transformers").setLevel(logging.ERROR) +logging.getLogger("torch").setLevel(logging.ERROR) + # Input Format: # The phi-4 model is best suited for prompts formatted as a chat, using special tokens. # Each message should follow this structure: @@ -36,6 +40,7 @@ def __init__(self, model_name: str = "microsoft/Phi-3.5-mini-instruct") -> None: torch.random.manual_seed(0) self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + logger.info(f"Using device: {self.device}") model_kwargs = { diff --git a/src/news_ask_ai/utils/logger.py b/src/news_ask_ai/utils/logger.py index 35aa59c..8fe9dea 100644 --- a/src/news_ask_ai/utils/logger.py +++ b/src/news_ask_ai/utils/logger.py @@ -1,7 +1,10 @@ import logging +from logging.handlers import RotatingFileHandler -def setup_logger(level: int = logging.INFO) -> logging.Logger: + + +def setup_logger(level: int = logging.INFO, log_file: str = "app.log") -> logging.Logger: """ Set up a logger with the specified name, log file, and level. @@ -13,22 +16,18 @@ def setup_logger(level: int = logging.INFO) -> logging.Logger: Returns: logging.Logger: Configured logger. """ - # Create a logger logger = logging.getLogger() if logger.hasHandlers(): logger.handlers.clear() logger.setLevel(level) - # Create a console handler for outputting logs to the console - console_handler = logging.StreamHandler() - console_handler.setLevel(level) + file_handler = RotatingFileHandler(log_file, maxBytes=1024 * 1024, backupCount=5) + file_handler.setLevel(level) - # Define a log format formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") - console_handler.setFormatter(formatter) + file_handler.setFormatter(formatter) - # Add handlers to the logger - logger.addHandler(console_handler) + logger.addHandler(file_handler) return logger