feat: add NORTH_STAR.md and MVP core loop (Telegram bot + LLM + proposal generator)
This commit is contained in:
committed by
GitHub
parent
c071fe6bf7
commit
6862c7d0b8
@@ -0,0 +1,119 @@
|
||||
"""Tests for steward.bot.telegram (handler logic)."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from telegram import Message, Update, User
|
||||
from telegram.ext import CallbackContext
|
||||
|
||||
from steward.bot.telegram import (
|
||||
_history,
|
||||
_is_allowed,
|
||||
clear_handler,
|
||||
message_handler,
|
||||
start_handler,
|
||||
)
|
||||
from steward.config import Settings
|
||||
from steward.llm.client import LLMClient
|
||||
|
||||
|
||||
def _make_settings(**kwargs) -> Settings:
|
||||
defaults = dict(
|
||||
telegram_bot_token="test-token",
|
||||
openai_api_key="test-key",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return Settings(**defaults)
|
||||
|
||||
|
||||
def _make_update(user_id: int = 12345, text: str = "hello") -> Update:
|
||||
user = MagicMock(spec=User)
|
||||
user.id = user_id
|
||||
|
||||
message = MagicMock(spec=Message)
|
||||
message.text = text
|
||||
message.reply_text = AsyncMock()
|
||||
|
||||
update = MagicMock(spec=Update)
|
||||
update.effective_user = user
|
||||
update.message = message
|
||||
return update
|
||||
|
||||
|
||||
def _make_context(settings: Settings, llm: LLMClient | None = None) -> CallbackContext: # type: ignore[type-arg]
|
||||
ctx = MagicMock(spec=CallbackContext)
|
||||
ctx.bot_data = {"settings": settings, "llm": llm}
|
||||
return ctx
|
||||
|
||||
|
||||
class TestIsAllowed:
|
||||
def test_no_allowlist_allows_everyone(self):
|
||||
settings = _make_settings(telegram_allowed_user_ids=[])
|
||||
assert _is_allowed(999, settings) is True
|
||||
|
||||
def test_allowlist_accepts_known_user(self):
|
||||
settings = _make_settings(telegram_allowed_user_ids=[1, 2, 3])
|
||||
assert _is_allowed(2, settings) is True
|
||||
|
||||
def test_allowlist_rejects_unknown_user(self):
|
||||
settings = _make_settings(telegram_allowed_user_ids=[1, 2, 3])
|
||||
assert _is_allowed(999, settings) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_handler_replies(monkeypatch):
|
||||
settings = _make_settings()
|
||||
update = _make_update()
|
||||
ctx = _make_context(settings)
|
||||
|
||||
await start_handler(update, ctx)
|
||||
|
||||
update.message.reply_text.assert_awaited_once()
|
||||
text = update.message.reply_text.call_args.args[0]
|
||||
assert "Steward" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_handler_ignores_disallowed_user():
|
||||
settings = _make_settings(telegram_allowed_user_ids=[9999])
|
||||
update = _make_update(user_id=1111)
|
||||
ctx = _make_context(settings)
|
||||
|
||||
await start_handler(update, ctx)
|
||||
|
||||
update.message.reply_text.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_handler_clears_history():
|
||||
settings = _make_settings()
|
||||
user_id = 42
|
||||
_history[user_id] = [{"role": "user", "content": "old msg"}]
|
||||
|
||||
update = _make_update(user_id=user_id)
|
||||
ctx = _make_context(settings)
|
||||
|
||||
await clear_handler(update, ctx)
|
||||
|
||||
assert _history[user_id] == []
|
||||
update.message.reply_text.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_handler_calls_llm_and_replies():
|
||||
settings = _make_settings()
|
||||
mock_llm = MagicMock(spec=LLMClient)
|
||||
mock_llm.chat = AsyncMock(return_value="LLM response")
|
||||
|
||||
update = _make_update(user_id=77, text="What is the weather?")
|
||||
ctx = _make_context(settings, llm=mock_llm)
|
||||
|
||||
_history[77].clear()
|
||||
await message_handler(update, ctx)
|
||||
|
||||
mock_llm.chat.assert_awaited_once()
|
||||
update.message.reply_text.assert_awaited_once()
|
||||
# History should now contain the user/assistant turn
|
||||
assert len(_history[77]) == 2
|
||||
assert _history[77][0]["role"] == "user"
|
||||
assert _history[77][1]["role"] == "assistant"
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Tests for steward.llm.client."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steward.config import Settings
|
||||
from steward.llm.client import LLMClient
|
||||
|
||||
|
||||
def _make_settings(**kwargs) -> Settings:
|
||||
defaults = dict(
|
||||
telegram_bot_token="test-token",
|
||||
openai_api_key="test-key",
|
||||
openai_model="gpt-4o",
|
||||
openai_system_prompt="You are Steward.",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return Settings(**defaults)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings():
|
||||
return _make_settings()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def llm_client(settings):
|
||||
return LLMClient(settings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_sends_correct_messages(llm_client):
|
||||
"""chat() should prepend the system prompt and append the user message."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices[0].message.content = "Hello from the LLM!"
|
||||
|
||||
with patch.object(
|
||||
llm_client._client.chat.completions,
|
||||
"create",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
) as mock_create:
|
||||
result = await llm_client.chat("Hi there")
|
||||
|
||||
assert result == "Hello from the LLM!"
|
||||
call_kwargs = mock_create.call_args.kwargs
|
||||
messages = call_kwargs["messages"]
|
||||
assert messages[0]["role"] == "system"
|
||||
assert messages[-1]["role"] == "user"
|
||||
assert messages[-1]["content"] == "Hi there"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_with_history(llm_client):
|
||||
"""chat() should include history between system and user messages."""
|
||||
history = [
|
||||
{"role": "user", "content": "previous question"},
|
||||
{"role": "assistant", "content": "previous answer"},
|
||||
]
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices[0].message.content = "reply"
|
||||
|
||||
with patch.object(
|
||||
llm_client._client.chat.completions,
|
||||
"create",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
) as mock_create:
|
||||
await llm_client.chat("new question", history=history)
|
||||
|
||||
messages = mock_create.call_args.kwargs["messages"]
|
||||
roles = [m["role"] for m in messages]
|
||||
assert roles == ["system", "user", "assistant", "user"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_custom_system_prompt(llm_client):
|
||||
"""chat() should use a custom system prompt when provided."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices[0].message.content = "ok"
|
||||
|
||||
with patch.object(
|
||||
llm_client._client.chat.completions,
|
||||
"create",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
) as mock_create:
|
||||
await llm_client.chat("msg", system_prompt="Custom prompt")
|
||||
|
||||
messages = mock_create.call_args.kwargs["messages"]
|
||||
assert messages[0]["content"] == "Custom prompt"
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for steward.proposals.generator."""
|
||||
|
||||
from datetime import UTC
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from steward.config import Settings
|
||||
from steward.llm.client import LLMClient
|
||||
from steward.proposals.generator import Proposal, ProposalGenerator
|
||||
|
||||
|
||||
def _make_settings(**kwargs) -> Settings:
|
||||
defaults = dict(
|
||||
openai_api_key="test-key",
|
||||
analysis_target_url="https://example.com/api/status",
|
||||
analysis_target_api_key="secret",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return Settings(**defaults)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings():
|
||||
return _make_settings()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm():
|
||||
llm = MagicMock(spec=LLMClient)
|
||||
llm.chat = AsyncMock(
|
||||
return_value="**Summary:** Everything is healthy.\n**Why:** No issues detected."
|
||||
)
|
||||
return llm
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_generates_proposal(settings, mock_llm):
|
||||
"""run() should fetch the URL and return a Proposal."""
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/api/status").mock(
|
||||
return_value=httpx.Response(200, text='{"status": "ok"}')
|
||||
)
|
||||
generator = ProposalGenerator(settings, mock_llm)
|
||||
proposal = await generator.run()
|
||||
|
||||
assert proposal is not None
|
||||
assert isinstance(proposal, Proposal)
|
||||
assert proposal.source_url == "https://example.com/api/status"
|
||||
assert proposal.raw_data == '{"status": "ok"}'
|
||||
mock_llm.chat.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_no_url_returns_none(mock_llm):
|
||||
"""run() should return None when analysis_target_url is not configured."""
|
||||
settings = _make_settings(analysis_target_url="")
|
||||
generator = ProposalGenerator(settings, mock_llm)
|
||||
result = await generator.run()
|
||||
assert result is None
|
||||
mock_llm.chat.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_http_error_returns_none(settings, mock_llm):
|
||||
"""run() should return None when the HTTP request fails."""
|
||||
with respx.mock:
|
||||
respx.get("https://example.com/api/status").mock(
|
||||
return_value=httpx.Response(500, text="Internal Server Error")
|
||||
)
|
||||
generator = ProposalGenerator(settings, mock_llm)
|
||||
proposal = await generator.run()
|
||||
|
||||
assert proposal is None
|
||||
mock_llm.chat.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_sends_bearer_token(settings, mock_llm):
|
||||
"""_fetch() should include ****** when api_key is configured."""
|
||||
with respx.mock:
|
||||
route = respx.get("https://example.com/api/status").mock(
|
||||
return_value=httpx.Response(200, text="data")
|
||||
)
|
||||
generator = ProposalGenerator(settings, mock_llm)
|
||||
await generator._fetch("https://example.com/api/status")
|
||||
|
||||
request = route.calls.last.request
|
||||
assert request.headers["Authorization"].startswith("Bearer ")
|
||||
|
||||
|
||||
def test_proposal_format_for_telegram():
|
||||
"""format_for_telegram() should contain the source URL and body."""
|
||||
from datetime import datetime
|
||||
p = Proposal(
|
||||
title="Test",
|
||||
body="**Summary:** All good.",
|
||||
source_url="https://example.com",
|
||||
generated_at=datetime(2026, 1, 1, 8, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
text = p.format_for_telegram()
|
||||
assert "https://example.com" in text
|
||||
assert "All good." in text
|
||||
assert "2026-01-01" in text
|
||||
Reference in New Issue
Block a user