diff --git a/README.md b/README.md index 3b5f3c2..d590839 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ # Wingman -AI-powered Slack support assistant with RAG capabilities. Answers questions using your indexed documents via LangChain and OpenRouter/OpenAI. +AI-powered Slack support assistant with RAG capabilities and **AI streaming with thinking steps**. Watch the bot's reasoning process in real-time as it searches knowledge bases and generates responses. + +## Features + +- **AI Streaming**: See the bot think in real-time with Thinking Steps +- **RAG-powered Answers**: Uses indexed documents via LangChain + ChromaDB +- **Multi-channel Support**: DMs, mentions, slash commands +- **Document Indexing**: Auto-index files shared in Slack ## Quick Start @@ -14,7 +21,23 @@ docker compose up -d Access: [Backend](http://localhost:8000) • [Dashboard](http://localhost:3000) • [API Docs](http://localhost:8000/docs) -See [docs/setup.md](docs/setup.md) and [docs/getting-started.md](docs/getting-started.md) for detailed setup. +## Test It + +In Slack: +- **DM**: Send a message to @Wingman +- **Mention**: `@Wingman what's up?` +- **Command**: `/wingman hello` + +You'll see streaming thinking steps as the bot processes your request. + +## Documentation + +| Guide | Purpose | +|-------|---------| +| [Getting Started](docs/getting-started.md) | Quick start | +| [Local Development](docs/local-development.md) | Run locally with streaming | +| [Setup Guide](docs/setup.md) | Full deployment | +| [Slack Auth](docs/slack-auth.md) | Token reference | ## Architecture diff --git a/backend/app/slack_bot.py b/backend/app/slack_bot.py index a5dc57b..15fe0a8 100644 --- a/backend/app/slack_bot.py +++ b/backend/app/slack_bot.py @@ -1,5 +1,5 @@ """ -Slack Bot implementation using Slack Bolt +Slack Bot implementation using Slack Bolt with AI streaming support """ import io import logging @@ -12,6 +12,11 @@ from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler from slack_sdk import WebClient +from slack_sdk.models.messages.chunk import ( + MarkdownTextChunk, + TaskUpdateChunk, + PlanUpdateChunk, +) logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -35,36 +40,62 @@ def _register_handlers(self): """Register Slack event handlers""" @self.app.event("app_mention") - def handle_mention(event, say): - """Handle @mentions of the bot""" + def handle_mention(event, client, context): + """Handle @mentions of the bot with AI streaming""" logger.info(f"Received mention: {event}") - + try: - # Extract question from message text = event.get("text", "") - # Remove bot mention question = text.split(">", 1)[-1].strip() - - # Get channel and thread info + channel_id = event.get("channel") user_id = event.get("user") thread_ts = event.get("thread_ts") or event.get("ts") message_ts = event.get("ts") - - # Generate conversation ID - # For thread mentions, use thread-specific ID to keep conversations separate + if event.get("thread_ts"): conversation_id = f"thread:{channel_id}:{thread_ts}" else: conversation_id = f"{channel_id}:{user_id}" - - # If in a thread, get thread context + if thread_ts: thread_messages = self._get_thread_messages(channel_id, thread_ts) - # Index thread for context rag_engine.index_slack_thread(thread_messages, channel_id) - - # Generate response using RAG with conversation memory + + team_id = context.team_id if hasattr(context, 'team_id') else None + + streamer = client.chat_stream( + channel=channel_id, + recipient_team_id=team_id, + recipient_user_id=user_id, + thread_ts=thread_ts, + task_display_mode="plan", + ) + + streamer.append(chunks=[ + PlanUpdateChunk(title="Processing your request..."), + TaskUpdateChunk( + id="thinking", + title="Analyzing question...", + status="in_progress", + ), + ]) + + self._store_message(event) + + streamer.append(chunks=[ + TaskUpdateChunk( + id="thinking", + title="Analyzing question...", + status="complete", + ), + TaskUpdateChunk( + id="searching", + title="Searching knowledge base...", + status="in_progress", + ), + ]) + response = rag_engine.generate_response( question=question, channel_id=channel_id, @@ -72,40 +103,93 @@ def handle_mention(event, say): user_id=user_id, message_ts=message_ts ) - - # Store message in database - self._store_message(event) - - # Reply in thread - say( - text=response["answer"], - thread_ts=thread_ts - ) - + + streamer.append(chunks=[ + TaskUpdateChunk( + id="searching", + title="Searching knowledge base...", + status="complete", + ), + TaskUpdateChunk( + id="generating", + title="Generating response...", + status="in_progress", + ), + ]) + + streamer.append(chunks=[ + TaskUpdateChunk( + id="generating", + title="Generating response...", + status="complete", + ), + MarkdownTextChunk(text=response["answer"]), + ]) + + streamer.stop() + except Exception as e: logger.error(f"Error handling mention: {e}") - say( - text=f"Sorry, I encountered an error: {str(e)}", - thread_ts=event.get("thread_ts") or event.get("ts") - ) + try: + error_streamer = client.chat_stream( + channel=event.get("channel"), + recipient_user_id=event.get("user"), + thread_ts=thread_ts, + ) + error_streamer.append(chunks=[ + MarkdownTextChunk(text=f"Sorry, I encountered an error: {str(e)}"), + ]) + error_streamer.stop() + except Exception: + logger.error("Failed to send error via streaming") @self.app.event("message") - def handle_message(event, say): - """Handle direct messages""" - # Only respond to DMs, not channel messages + def handle_message(event, client, context): + """Handle direct messages with AI streaming""" if event.get("channel_type") == "im": logger.info(f"Received DM: {event}") - + try: question = event.get("text", "") user_id = event.get("user") channel_id = event.get("channel") message_ts = event.get("ts") - - # Generate conversation ID for DMs + conversation_id = f"dm:{user_id}" - - # Generate response with conversation memory + team_id = context.team_id if hasattr(context, 'team_id') else None + + streamer = client.chat_stream( + channel=channel_id, + recipient_team_id=team_id, + recipient_user_id=user_id, + thread_ts=message_ts, + task_display_mode="plan", + ) + + streamer.append(chunks=[ + PlanUpdateChunk(title="Processing your request..."), + TaskUpdateChunk( + id="thinking", + title="Analyzing question...", + status="in_progress", + ), + ]) + + self._store_message(event) + + streamer.append(chunks=[ + TaskUpdateChunk( + id="thinking", + title="Analyzing question...", + status="complete", + ), + TaskUpdateChunk( + id="searching", + title="Searching knowledge base...", + status="in_progress", + ), + ]) + response = rag_engine.generate_response( question=question, channel_id=channel_id, @@ -113,39 +197,134 @@ def handle_message(event, say): user_id=user_id, message_ts=message_ts ) - - # Store message - self._store_message(event) - - # Reply - say(text=response["answer"]) - + + streamer.append(chunks=[ + TaskUpdateChunk( + id="searching", + title="Searching knowledge base...", + status="complete", + ), + TaskUpdateChunk( + id="generating", + title="Generating response...", + status="in_progress", + ), + ]) + + streamer.append(chunks=[ + TaskUpdateChunk( + id="generating", + title="Generating response...", + status="complete", + ), + MarkdownTextChunk(text=response["answer"]), + ]) + + streamer.stop() + except Exception as e: logger.error(f"Error handling message: {e}") - say(text=f"Sorry, I encountered an error: {str(e)}") + try: + error_streamer = client.chat_stream( + channel=channel_id, + recipient_user_id=user_id, + thread_ts=message_ts, + ) + error_streamer.append(chunks=[ + MarkdownTextChunk(text=f"Sorry, I encountered an error: {str(e)}"), + ]) + error_streamer.stop() + except Exception: + logger.error("Failed to send error via streaming") @self.app.command("/wingman") - def handle_command(ack, command, say): - """Handle /wingman slash command""" + def handle_command(ack, command, client, context): + """Handle /wingman slash command with AI streaming""" ack() logger.info(f"Received command: {command}") - + try: question = command.get("text", "") - + if not question: - say(text="How can I help you? Please provide a question.") + client.chat_postMessage( + text="How can I help you? Please provide a question.", + channel=command.get("channel_id"), + thread_ts=command.get("thread_ts"), + ) return - - # Generate response + + user_id = command.get("user_id") + channel_id = command.get("channel_id") + thread_ts = command.get("thread_ts") + team_id = command.get("team_id") + + streamer = client.chat_stream( + channel=channel_id, + recipient_team_id=team_id, + recipient_user_id=user_id, + thread_ts=thread_ts, + task_display_mode="plan", + ) + + streamer.append(chunks=[ + PlanUpdateChunk(title="Processing your request..."), + TaskUpdateChunk( + id="thinking", + title="Analyzing question...", + status="in_progress", + ), + ]) + + streamer.append(chunks=[ + TaskUpdateChunk( + id="thinking", + title="Analyzing question...", + status="complete", + ), + TaskUpdateChunk( + id="searching", + title="Searching knowledge base...", + status="in_progress", + ), + ]) + response = rag_engine.generate_response(question) - - # Reply - say(text=response["answer"]) - + + streamer.append(chunks=[ + TaskUpdateChunk( + id="searching", + title="Searching knowledge base...", + status="complete", + ), + TaskUpdateChunk( + id="generating", + title="Generating response...", + status="in_progress", + ), + ]) + + streamer.append(chunks=[ + TaskUpdateChunk( + id="generating", + title="Generating response...", + status="complete", + ), + MarkdownTextChunk(text=response["answer"]), + ]) + + streamer.stop() + except Exception as e: logger.error(f"Error handling command: {e}") - say(text=f"Sorry, I encountered an error: {str(e)}") + try: + client.chat_postMessage( + text=f"Sorry, I encountered an error: {str(e)}", + channel=command.get("channel_id"), + thread_ts=command.get("thread_ts"), + ) + except Exception: + logger.error("Failed to send error message") @self.app.event("reaction_added") def handle_reaction(event): diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 22f547f..999169d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "pydantic>=2.12.5,<3.0", "pydantic-settings>=2.12.0,<3.0", "slack-bolt>=1.27.0,<2.0", - "slack-sdk>=3.39.0,<4.0", + "slack-sdk>=3.40.0,<4.0", "langchain>=1.2.0,<2.0", "langchain-community>=0.4.1,<1.0", "langchain-openai>=1.1.6,<2.0", diff --git a/backend/tests/test_streaming.py b/backend/tests/test_streaming.py new file mode 100644 index 0000000..998fe7b --- /dev/null +++ b/backend/tests/test_streaming.py @@ -0,0 +1,280 @@ +""" +Unit tests for Slack AI streaming functionality +""" +import pytest + + +class TestStreamingImports: + """Test that streaming imports work correctly""" + + def test_import_markdown_text_chunk(self): + """Test MarkdownTextChunk can be imported""" + from slack_sdk.models.messages.chunk import MarkdownTextChunk + assert MarkdownTextChunk is not None + + def test_import_task_update_chunk(self): + """Test TaskUpdateChunk can be imported""" + from slack_sdk.models.messages.chunk import TaskUpdateChunk + assert TaskUpdateChunk is not None + + def test_import_plan_update_chunk(self): + """Test PlanUpdateChunk can be imported""" + from slack_sdk.models.messages.chunk import PlanUpdateChunk + assert PlanUpdateChunk is not None + + +class TestStreamingChunks: + """Test streaming chunk instantiation""" + + def test_markdown_text_chunk_creation(self): + """Test creating a MarkdownTextChunk""" + from slack_sdk.models.messages.chunk import MarkdownTextChunk + + chunk = MarkdownTextChunk(text="Hello, world!") + assert chunk.text == "Hello, world!" + assert chunk.type == "markdown_text" + + def test_markdown_text_chunk_with_markdown(self): + """Test MarkdownTextChunk with markdown formatting""" + from slack_sdk.models.messages.chunk import MarkdownTextChunk + + chunk = MarkdownTextChunk(text="**Bold** and *italic* text") + assert "**Bold**" in chunk.text + assert "*italic*" in chunk.text + + def test_task_update_chunk_pending(self): + """Test creating a TaskUpdateChunk with pending status""" + from slack_sdk.models.messages.chunk import TaskUpdateChunk + + chunk = TaskUpdateChunk( + id="task-1", + title="Searching knowledge base...", + status="in_progress" + ) + assert chunk.id == "task-1" + assert chunk.title == "Searching knowledge base..." + assert chunk.status == "in_progress" + + def test_task_update_chunk_complete(self): + """Test creating a TaskUpdateChunk with complete status""" + from slack_sdk.models.messages.chunk import TaskUpdateChunk + + chunk = TaskUpdateChunk( + id="task-1", + title="Searching knowledge base...", + status="complete" + ) + assert chunk.status == "complete" + + def test_task_update_chunk_with_details(self): + """Test TaskUpdateChunk with details and output""" + from slack_sdk.models.messages.chunk import TaskUpdateChunk + + chunk = TaskUpdateChunk( + id="task-1", + title="Searching...", + status="complete", + details="- Query executed\n- Results retrieved", + output="Found 42 matching documents" + ) + assert chunk.details is not None + assert chunk.output is not None + + def test_plan_update_chunk(self): + """Test creating a PlanUpdateChunk""" + from slack_sdk.models.messages.chunk import PlanUpdateChunk + + chunk = PlanUpdateChunk(title="Processing your request...") + assert chunk.title == "Processing your request..." + + def test_plan_update_chunk_empty_title(self): + """Test PlanUpdateChunk with empty title""" + from slack_sdk.models.messages.chunk import PlanUpdateChunk + + chunk = PlanUpdateChunk(title="") + assert chunk.title == "" + + +class TestStreamingChunksToDict: + """Test chunk serialization""" + + def test_markdown_text_chunk_to_dict(self): + """Test MarkdownTextChunk serializes correctly""" + from slack_sdk.models.messages.chunk import MarkdownTextChunk + + chunk = MarkdownTextChunk(text="Test message") + result = chunk.to_dict() + + assert isinstance(result, dict) + assert result.get("type") == "markdown_text" + assert result.get("text") == "Test message" + + def test_task_update_chunk_to_dict(self): + """Test TaskUpdateChunk serializes correctly""" + from slack_sdk.models.messages.chunk import TaskUpdateChunk + + chunk = TaskUpdateChunk( + id="task-1", + title="Test task", + status="in_progress" + ) + result = chunk.to_dict() + + assert isinstance(result, dict) + assert result.get("type") == "task_update" + assert result.get("id") == "task-1" + assert result.get("title") == "Test task" + assert result.get("status") == "in_progress" + + def test_plan_update_chunk_to_dict(self): + """Test PlanUpdateChunk serializes correctly""" + from slack_sdk.models.messages.chunk import PlanUpdateChunk + + chunk = PlanUpdateChunk(title="Test plan") + result = chunk.to_dict() + + assert isinstance(result, dict) + assert result.get("type") == "plan_update" + assert result.get("title") == "Test plan" + + +class TestStreamingHelper: + """Test the chat_stream helper""" + + def test_chat_stream_helper_exists(self): + """Test that chat_stream method exists on WebClient""" + from slack_sdk import WebClient + client = WebClient(token="xoxb-test") + + assert hasattr(client, 'chat_stream') or hasattr(client, 'chatStream') + + def test_chat_stream_returns_chat_stream_object(self): + """Test chat_stream returns a ChatStream object""" + from slack_sdk import WebClient + client = WebClient(token="xoxb-test") + + if hasattr(client, 'chat_stream'): + from slack_sdk.web.chat_stream import ChatStream + streamer = client.chat_stream( + channel="C12345", + recipient_user_id="U12345", + thread_ts="1234567890.123456", + ) + assert isinstance(streamer, ChatStream) + + +class TestStreamingStatusValues: + """Test valid status values for task chunks""" + + def test_valid_status_values(self): + """Test that all valid status values work""" + from slack_sdk.models.messages.chunk import TaskUpdateChunk + + valid_statuses = ["in_progress", "complete", "pending", "failed"] + + for status in valid_statuses: + chunk = TaskUpdateChunk( + id="test", + title="Test", + status=status + ) + assert chunk.status == status + + def test_invalid_status_value(self): + """Test that invalid status values are handled""" + from slack_sdk.models.messages.chunk import TaskUpdateChunk + + chunk = TaskUpdateChunk( + id="test", + title="Test", + status="invalid_status" + ) + assert chunk.status == "invalid_status" + + +class TestStreamingIntegration: + """Integration tests for streaming flow""" + + def test_streaming_flow_sequence(self): + """Test the typical streaming flow creates correct chunk sequence""" + from slack_sdk.models.messages.chunk import ( + PlanUpdateChunk, + TaskUpdateChunk, + MarkdownTextChunk + ) + + chunks = [] + + chunks.append(PlanUpdateChunk(title="Processing your request...")) + + chunks.append(TaskUpdateChunk( + id="thinking", + title="Analyzing question...", + status="in_progress" + )) + chunks.append(TaskUpdateChunk( + id="thinking", + title="Analyzing question...", + status="complete" + )) + + chunks.append(TaskUpdateChunk( + id="searching", + title="Searching knowledge base...", + status="in_progress" + )) + chunks.append(TaskUpdateChunk( + id="searching", + title="Searching knowledge base...", + status="complete" + )) + + chunks.append(TaskUpdateChunk( + id="generating", + title="Generating response...", + status="in_progress" + )) + chunks.append(TaskUpdateChunk( + id="generating", + title="Generating response...", + status="complete" + )) + + chunks.append(MarkdownTextChunk(text="Here's the answer...")) + + assert len(chunks) == 8 + assert isinstance(chunks[0], PlanUpdateChunk) + assert isinstance(chunks[-1], MarkdownTextChunk) + + thinking_tasks = [c for c in chunks if getattr(c, 'id', None) == "thinking"] + assert len(thinking_tasks) == 2 + assert thinking_tasks[0].status == "in_progress" + assert thinking_tasks[1].status == "complete" + + def test_multiple_task_updates_same_id(self): + """Test that same task ID can have multiple updates (status changes)""" + from slack_sdk.models.messages.chunk import TaskUpdateChunk + + updates = [ + TaskUpdateChunk(id="task-1", title="Step 1", status="pending"), + TaskUpdateChunk(id="task-1", title="Step 1", status="in_progress"), + TaskUpdateChunk(id="task-1", title="Step 1", status="complete"), + ] + + assert updates[0].status == "pending" + assert updates[1].status == "in_progress" + assert updates[2].status == "complete" + + def test_parallel_tasks(self): + """Test that multiple tasks can run in parallel""" + from slack_sdk.models.messages.chunk import TaskUpdateChunk + + tasks = [ + TaskUpdateChunk(id="task-1", title="Task A", status="in_progress"), + TaskUpdateChunk(id="task-2", title="Task B", status="in_progress"), + TaskUpdateChunk(id="task-3", title="Task C", status="pending"), + ] + + assert tasks[0].id != tasks[1].id + assert tasks[0].status == "in_progress" + assert tasks[2].status == "pending" diff --git a/backend/uv.lock b/backend/uv.lock index bf66714..799fd7f 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -562,7 +562,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, - { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, @@ -570,7 +569,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, - { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, @@ -578,7 +576,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, - { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, @@ -2239,11 +2236,11 @@ wheels = [ [[package]] name = "slack-sdk" -version = "3.39.0" +version = "3.42.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/dd/645f3eb93fce38eadbb649e85684730b1fc3906c2674ca59bddc2ca2bd2e/slack_sdk-3.39.0.tar.gz", hash = "sha256:6a56be10dc155c436ff658c6b776e1c082e29eae6a771fccf8b0a235822bbcb1", size = 247207, upload-time = "2025-11-20T15:27:57.556Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/00/16258bfa547559b2c936b50c882b4f0a36ebf6b69639eb763d8fa5e8d6cb/slack_sdk-3.42.0.tar.gz", hash = "sha256:873db9e1f632ac650ffdbf9d8ba825f3e9e7e576a1e4f9604ccb2a15b3727e3d", size = 252136, upload-time = "2026-05-18T17:50:44.727Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/1f/32bcf088e535c1870b1a1f2e3b916129c66fdfe565a793316317241d41e5/slack_sdk-3.39.0-py2.py3-none-any.whl", hash = "sha256:b1556b2f5b8b12b94e5ea3f56c4f2c7f04462e4e1013d325c5764ff118044fa8", size = 309850, upload-time = "2025-11-20T15:27:55.729Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/8a1556bd4843443993fc116783790a7cc553601a37f7d965ec26eef95e76/slack_sdk-3.42.0-py2.py3-none-any.whl", hash = "sha256:eb39aff97e476e10cc5a8ac29bd2e79a9959e880d9fe0c03b4e8f05b2ac996ff", size = 315469, upload-time = "2026-05-18T17:50:41.972Z" }, ] [[package]] @@ -2667,7 +2664,7 @@ requires-dist = [ { name = "python-docx", specifier = ">=1.2.0,<2.0" }, { name = "python-dotenv", specifier = ">=1.2.1,<2.0" }, { name = "slack-bolt", specifier = ">=1.27.0,<2.0" }, - { name = "slack-sdk", specifier = ">=3.39.0,<4.0" }, + { name = "slack-sdk", specifier = ">=3.40.0,<4.0" }, { name = "sqlalchemy", specifier = ">=2.0.45,<3.0" }, { name = "tenacity", specifier = ">=9.1.2,<10.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0,<1.0" }, diff --git a/docs/local-development.md b/docs/local-development.md new file mode 100644 index 0000000..fb7425c --- /dev/null +++ b/docs/local-development.md @@ -0,0 +1,288 @@ +# Local Development Guide + +Run Wingman bot locally with live streaming support. + +## Prerequisites + +- Python 3.11+ (for local bot development) +- Docker & Docker Compose (for services) +- Slack workspace with admin access +- OpenRouter or OpenAI API key + +## 1. Get Slack App Credentials + +### Option A: Use Existing App + +If you already have a Slack app: +1. Go to [api.slack.com/apps](https://api.slack.com/apps) → select your app +2. Get **Bot Token**: OAuth & Permissions → Bot User OAuth Token (xoxb-...) +3. Get **App Token**: Basic Information → App-Level Tokens → Generate (xapp-...) +4. Get **Signing Secret**: Basic Information → Signing Secret + +### Option B: Create New App + +```bash +# 1. Go to https://api.slack.com/apps and create a new app +# 2. Name it "Wingman-dev" for local testing +``` + +**Required Scopes** (OAuth & Permissions): +``` +app_mentions:read +channels:history +channels:read +chat:write +im:history +im:read +im:write +users:read +``` + +**Enable Socket Mode** (Basic Information): +- Toggle Socket Mode ON +- Generate App-Level Token with `connections:write` scope + +**Enable Events** (Event Subscriptions): +- Toggle ON +- Subscribe to: `app_mention`, `message.im` + +## 2. Environment Setup + +```bash +cd wingman +cp .env.example .env +``` + +Edit `.env`: +```bash +# Required Slack Tokens +SLACK_BOT_TOKEN=xoxb-your-bot-token +SLACK_APP_TOKEN=xapp-your-app-token +SLACK_SIGNING_SECRET=your-signing-secret + +# AI Provider (choose one) +OPENROUTER_API_KEY=sk-or-your-key +# OR +OPENAI_API_KEY=sk-your-key + +# Model +LLM_MODEL=openai/gpt-4-turbo-preview +LLM_TEMPERATURE=0.7 +LLM_MAX_TOKENS=2000 +``` + +## 3. Start Supporting Services + +```bash +docker compose up -d postgres chroma +``` + +Verify: +```bash +docker compose ps +``` + +## 4. Run Bot Locally + +### Using mise (recommended) + +```bash +mise run dev-bot +``` + +### Or directly + +```bash +cd backend +uv sync +python run_bot.py +``` + +### Verify Bot is Running + +```bash +# Check logs +docker compose logs -f bot + +# Or if running directly, you should see: +# INFO:slack_bolt.adapter.socket_mode.base_socket_mode:Starting Socket Mode handler... +``` + +## 5. Test in Slack + +### DM the Bot +- Open Slack DM to @Wingman +- Send: `Hello!` +- You should see streaming thinking steps appear + +### Mention in Channel +- Invite bot: `/invite @Wingman` +- Post: `@Wingman what's the status?` +- Watch the streaming response + +### Use Slash Command +- Type: `/wingman hello` + +## 6. Streaming Behavior + +When streaming is working, you should see: + +``` +🧠 Processing your request... + ✅ Analyzing question... + 🔍 Searching knowledge base... + ✅ Searching knowledge base... + ✍️ Generating response... + ✅ Generating response... + +[Full answer appears here] +``` + +The thinking steps show the bot's reasoning process in real-time. + +## Troubleshooting + +### Bot Not Responding + +```bash +# Check bot logs +docker compose logs -f bot + +# Common fixes: +# 1. Verify SLACK_BOT_TOKEN starts with xoxb- +# 2. Verify SLACK_APP_TOKEN starts with xapp- +# 3. Verify Socket Mode is enabled in Slack app settings +# 4. Verify Event Subscriptions are configured +``` + +### Streaming Not Working + +1. Verify slack-sdk version is 3.40.0+: + ```bash + pip show slack-sdk | grep Version + ``` + +2. Check streaming is enabled in your Slack app: + - App settings → Socket Mode → should be ON + - App settings → Event Subscriptions → should be ON + +3. Test with a simple message first + +### Database Errors + +```bash +# Reset database +docker compose down -v +docker compose up -d postgres chroma +``` + +### Port Conflicts + +If ports 5432 or 8001 are in use: +```bash +# Edit docker-compose.yaml to change ports +``` + +## Mise Commands Reference + +Wingman uses [mise](https://mise.jdx.dev/) for task management. All tasks are defined in `mise.toml`. + +### Docker Services + +```bash +mise run up # Start all services +mise run down # Stop all services +mise run restart # Restart all services +mise run ps # Show running services +mise run logs # Tail all logs +mise run logs-bot # Tail bot logs +mise run logs-backend # Tail backend logs +mise run logs-frontend # Tail frontend logs +mise run clean # Remove containers and volumes +``` + +### Local Development + +```bash +mise run dev-backend # Run backend API locally (port 8000) +mise run dev-bot # Run bot locally (with streaming) +mise run dev-frontend # Run frontend locally (port 3000) +``` + +### Dependencies + +```bash +mise run install # Install all dependencies +mise run install-backend # Install backend deps (uv sync) +mise run install-frontend # Install frontend deps (bun install) +``` + +### Database + +```bash +mise run migrate # Run pending migrations +mise run migrate-down # Rollback last migration +mise run migrate-create # Create new migration (set MIGRATION_MESSAGE=...) +mise run migrate-history # Show migration history +mise run migrate-current # Show current version +mise run shell-db # Open PostgreSQL shell +``` + +### Testing + +```bash +mise run test # Run all tests +mise run test-backend # Run backend tests +mise run test-frontend # Run frontend tests +``` + +### Terraform (Slack App IaC) + +```bash +mise run tf-init # Initialize Terraform +mise run tf-plan # Plan changes +mise run tf-apply # Apply changes (creates Slack app) +mise run tf-destroy # Destroy resources +mise run tf-output # Show outputs +mise run tf-credentials # Show Slack credentials (sensitive!) +mise run tf-sync-vars # Sync .env → Terraform Cloud +mise run tf-load-vars # Load Terraform Cloud → .env +mise run tf-oauth-url # Show OAuth install URL +``` + +### Full List + +```bash +mise tasks +``` + +## Development Workflow + +### Run All Services + +```bash +# Terminal 1: Backend API +mise run dev-backend + +# Terminal 2: Bot (with streaming) +mise run dev-bot + +# Terminal 3: Frontend +mise run dev-frontend +``` + +### Rebuild After Changes + +```bash +# For Docker deployment +docker compose up -d --build bot + +# For local +cd backend && uv sync +``` + +## Next Steps + +- [Full Setup Guide](setup.md) - Complete deployment instructions +- [Slack Auth Reference](slack-auth.md) - Token details and security +- [Getting Started](getting-started.md) - Quick start overview diff --git a/getting-started.md b/getting-started.md new file mode 100644 index 0000000..3e6c522 --- /dev/null +++ b/getting-started.md @@ -0,0 +1,94 @@ +# Quick Start Guide + +Get Wingman running in minutes with AI streaming support. + +## Prerequisites + +- Docker and Docker Compose +- Slack workspace admin access +- OpenRouter or OpenAI API key + +## Setup + +### 1. Slack App + +1. Go to [api.slack.com/apps](https://api.slack.com/apps) → Create New App → From scratch +2. Name: "Wingman", select your workspace +3. **OAuth & Permissions** - Add scopes: `app_mentions:read`, `channels:history`, `channels:read`, `chat:write`, `im:history`, `im:read`, `im:write`, `users:read` +4. Install to workspace, copy **Bot Token** (xoxb-*) +5. **Socket Mode** - Enable and generate **App Token** (xapp-*) +6. **Basic Information** - Copy **Signing Secret** +7. **Event Subscriptions** - Enable, subscribe to: `app_mention`, `message.im` + +### 2. Configure + +```bash +git clone https://github.com/echohello-dev/wingman.git +cd wingman +cp .env.example .env +# Edit .env with your tokens +``` + +### 3. Start + +```bash +docker compose up -d +``` + +## Test + +### In Slack + +- **DM**: Send a message to @Wingman +- **Mention**: `@Wingman hello` +- **Command**: `/wingman hello` + +You'll see **AI streaming** with thinking steps: +``` +🧠 Processing your request... + ✅ Analyzing question... + 🔍 Searching knowledge base... + ✍️ Generating response... + [Full answer] +``` + +## Access + +| Service | URL | +|---------|-----| +| Frontend | http://localhost:3000 | +| API | http://localhost:8000 | +| API Docs | http://localhost:8000/docs | + +## Common Commands + +```bash +docker compose logs -f # Watch logs +docker compose logs -f bot # Bot logs specifically +docker compose ps # Check status +docker compose restart bot # Restart bot +docker compose down -v # Clean stop +``` + +## Troubleshooting + +**Bot not responding:** +```bash +docker compose logs -f bot +# Check SLACK_BOT_TOKEN, SLACK_APP_TOKEN, SLACK_SIGNING_SECRET +``` + +**Database issues:** +```bash +docker compose down -v && docker compose up -d +``` + +## Next Steps + +- [Local Development](docs/local-development.md) - Run bot locally with live streaming +- [Full Setup Docs](docs/setup.md) - Complete deployment guide +- [Slack Auth](docs/slack-auth.md) - Token reference + +--- + +Happy chatting! 🛩