107 lines
3.5 KiB
Python
107 lines
3.5 KiB
Python
"""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
|