"""Proposal data model and generator. This module implements a minimal "generate proposal" workflow: 1. Fetch data from a target API endpoint. 2. Ask the LLM to analyse the data and produce a structured proposal. 3. Return the proposal for delivery (e.g. via Telegram). The Proposal dataclass is intentionally simple for the MVP – it captures the fields described in the manifesto (why, confidence, evidence, expected outcome, rollback strategy) without any persistence layer yet. """ from __future__ import annotations import logging from dataclasses import dataclass, field from datetime import UTC, datetime import httpx from steward.config import Settings from steward.llm.client import LLMClient logger = logging.getLogger(__name__) _ANALYSIS_SYSTEM_PROMPT = ( "You are Steward, an AI operations platform. " "You have been given raw data from an internal API. " "Analyse the data and produce a concise proposal in the following format:\n\n" "**Summary:** \n" "**Why:** \n" "**Evidence:** \n" "**Expected outcome:** \n" "**Rollback strategy:** \n" "**Confidence:** - \n\n" "Be conservative. If the data is healthy and no action is needed, say so explicitly." ) @dataclass class Proposal: """A structured action proposal generated by Steward.""" title: str body: str source_url: str generated_at: datetime = field(default_factory=lambda: datetime.now(UTC)) raw_data: str = "" def format_for_telegram(self) -> str: """Return a Markdown-formatted string suitable for a Telegram message.""" ts = self.generated_at.strftime("%Y-%m-%d %H:%M UTC") return ( f"\U0001f50d *Steward Proposal*\n" f"_{ts}_\n\n" f"*Source:* `{self.source_url}`\n\n" f"{self.body}" ) class ProposalGenerator: """Fetches data from a target API and generates a proposal via the LLM.""" def __init__(self, settings: Settings, llm: LLMClient) -> None: self._settings = settings self._llm = llm async def run(self) -> Proposal | None: """Fetch the target API and return a Proposal, or None on error.""" url = self._settings.analysis_target_url if not url: logger.warning("analysis_target_url is not configured - skipping proposal generation") return None raw = await self._fetch(url) if raw is None: return None body = await self._llm.chat( f"Here is the API response from {url}:\n\n{raw}", system_prompt=_ANALYSIS_SYSTEM_PROMPT, ) return Proposal( title="Daily Analysis", body=body, source_url=url, raw_data=raw, ) async def _fetch(self, url: str) -> str | None: """Fetch the URL and return the response body as text.""" headers: dict[str, str] = {} api_key = self._settings.analysis_target_api_key if api_key: headers["Authorization"] = "Bearer " + api_key try: async with httpx.AsyncClient(timeout=30) as client: response = await client.get(url, headers=headers) response.raise_for_status() return response.text except httpx.HTTPError as exc: logger.error("Failed to fetch %s: %s", url, exc) return None