107 lines
3.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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:** <one-sentence summary>\n"
"**Why:** <reason this proposal matters>\n"
"**Evidence:** <key data points from the API response>\n"
"**Expected outcome:** <what will improve if adopted>\n"
"**Rollback strategy:** <how to undo if things go wrong>\n"
"**Confidence:** <Low | Medium | High> - <brief justification>\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