"""The agent pipeline end to end, with the daily quota and the `agent_requests` journal (SPEC L-10…L-17).""" import logging from dataclasses import dataclass from datetime import datetime, timedelta from enum import StrEnum from pathlib import Path from sqlalchemy import func, select from app.agent import guard, planner from app.agent.log_query import AgentLogQuery from app.agent.prefilter import Prefilter, load_rules from app.db.models import AgentRequest, Verdict, utcnow from app.db.session import SessionMaker from app.llm.provider import LLMError, LLMProvider log = logging.getLogger(__name__) DAILY_QUOTA = 30 # L-17 class Outcome(StrEnum): OK = "ok" ATTACK = "attack" # prefilter or guard: refuse politely, alert the admins QUOTA = "quota" UNAVAILABLE = "unavailable" # the LLM is down NOT_UNDERSTOOD = "not_understood" # the planner's reply failed validation @dataclass(frozen=True) class Answer: outcome: Outcome query: AgentLogQuery | None = None reason: str = "" # for the admins' alert and the journal; never shown to the asker stage: str = "" # prefilter | guard @dataclass(frozen=True) class Prompts: guard: str planner: str def load_prompts(config_dir: Path) -> Prompts: d = config_dir / "prompts" return Prompts( guard=(d / "guard.md").read_text(encoding="utf-8"), planner=(d / "planner.md").read_text(encoding="utf-8") ) class Agent: def __init__( self, llm: LLMProvider, sessions: SessionMaker, prefilter: Prefilter, prompts: Prompts, quota: int = DAILY_QUOTA ) -> None: self._llm = llm self._sessions = sessions self._prefilter = prefilter self._prompts = prompts self.quota = quota @classmethod def from_config(cls, llm: LLMProvider, sessions: SessionMaker, config_dir: Path) -> "Agent": return cls(llm, sessions, Prefilter(load_rules(config_dir)), load_prompts(config_dir)) async def used_today(self, user_id: int, now: datetime) -> int: async with self._sessions() as session: count = await session.scalar( select(func.count()) .select_from(AgentRequest) .where(AgentRequest.user_id == user_id, AgentRequest.at >= now - timedelta(days=1)) ) return int(count or 0) async def ask(self, user_id: int, prompt: str, containers: list[str], default: str) -> Answer: now = utcnow() prompt = prompt.strip() if await self.used_today(user_id, now) >= self.quota: return Answer(Outcome.QUOTA) reason = self._prefilter.check(prompt) if reason is not None: await self._journal(user_id, prompt, Verdict.ATTACK, f"prefilter: {reason}", None, 0) return Answer(Outcome.ATTACK, reason=reason, stage="prefilter") tokens = 0 try: verdict = await guard.classify(self._llm, self._prompts.guard, prompt) tokens += verdict.tokens if verdict.verdict is not Verdict.SAFE: await self._journal(user_id, prompt, Verdict.ATTACK, f"guard: {verdict.reason}", None, tokens) return Answer(Outcome.ATTACK, reason=verdict.reason, stage="guard") result = await planner.plan(self._llm, self._prompts.planner, prompt, containers, default, now) tokens += result.tokens except LLMError as e: log.warning("agent: llm unavailable: %s", e) await self._journal(user_id, prompt, Verdict.ERROR, f"llm: {str(e)[:200]}", None, tokens) return Answer(Outcome.UNAVAILABLE) if result.query is None: await self._journal(user_id, prompt, Verdict.ERROR, f"planner: {result.error}", None, tokens) return Answer(Outcome.NOT_UNDERSTOOD, reason=result.error) await self._journal(user_id, prompt, Verdict.SAFE, None, result.query.model_dump(), tokens) return Answer(Outcome.OK, query=result.query) async def _journal( self, user_id: int, prompt: str, verdict: Verdict, reason: str | None, query: dict[str, object] | None, tokens: int, ) -> None: async with self._sessions.begin() as session: session.add( AgentRequest( user_id=user_id, prompt=prompt, verdict=verdict, reason=reason, log_query=query, tokens=tokens ) )