Files
gogo-telefon/gogo/agent/llm.py
Senad Uka 0d0838e6e2 M2: agent logic in text mode
- LLMProvider interface: AnthropicLLM + ScriptedLLM test fake
- System prompt composition from super-admin template + structured tenant
  data only (§6.3); auto-generated greetings with recording disclosure,
  out-of-hours variant, chat variant
- Agent tools (§6.5): get_salon_info, check_availability (≤8 slots to model,
  agent offers ≤3), submit_booking_request (caller-ID fallback, hallucinated
  service-id guard), take_message
- Conversation loop with tool dispatch, round-limit guard, transcript capture,
  outcome classification (request_created/message_taken/info_only/abandoned)
- Terminal playground (python -m gogo.agent.cli) + demo seed (python -m gogo.seed)
- Deterministic loop tests (ScriptedLLM) + Appendix A golden-scenario
  acceptance tests against a real LLM (skipped without an API key)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:53:08 +02:00

114 lines
3.0 KiB
Python

"""LLMProvider interface (§4) — Anthropic implementation + scripted fake for tests.
The shape mirrors the Anthropic Messages API tool-use loop: the provider returns
text and/or tool_use blocks; the conversation loop executes tools and feeds
tool_result blocks back.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Protocol
from gogo.config import get_settings
@dataclass
class ToolUse:
id: str
name: str
input: dict[str, Any]
@dataclass
class LLMResponse:
text: str # concatenated text blocks ("" if pure tool call)
tool_uses: list[ToolUse] = field(default_factory=list)
stop_reason: str = "end_turn"
class LLMProvider(Protocol):
async def complete(
self,
*,
system: str,
messages: list[dict],
tools: list[dict],
model: str | None = None,
max_tokens: int = 1024,
) -> LLMResponse: ...
class AnthropicLLM:
def __init__(self, api_key: str | None = None):
import anthropic
self._client = anthropic.AsyncAnthropic(
api_key=api_key or get_settings().anthropic_api_key or None
)
async def complete(
self,
*,
system: str,
messages: list[dict],
tools: list[dict],
model: str | None = None,
max_tokens: int = 1024,
) -> LLMResponse:
resp = await self._client.messages.create(
model=model or get_settings().llm_model,
system=system,
messages=messages,
tools=tools,
max_tokens=max_tokens,
)
text_parts: list[str] = []
tool_uses: list[ToolUse] = []
for block in resp.content:
if block.type == "text":
text_parts.append(block.text)
elif block.type == "tool_use":
tool_uses.append(ToolUse(id=block.id, name=block.name, input=block.input))
return LLMResponse(
text="\n".join(text_parts).strip(),
tool_uses=tool_uses,
stop_reason=resp.stop_reason or "end_turn",
)
class ScriptedLLM:
"""Deterministic fake: replays a fixed sequence of LLMResponses.
Used to test the conversation loop machinery (tool dispatch, transcripts,
guards) without a real model.
"""
def __init__(self, responses: list[LLMResponse]):
self._responses = list(responses)
self.calls: list[dict] = [] # recorded kwargs for assertions
async def complete(self, **kwargs) -> LLMResponse:
import copy
self.calls.append(copy.deepcopy(kwargs)) # snapshot: the history list mutates
if not self._responses:
return LLMResponse(text="Doviđenja!", stop_reason="end_turn")
return self._responses.pop(0)
_llm: LLMProvider | None = None
def get_llm() -> LLMProvider:
global _llm
if _llm is None:
_llm = AnthropicLLM()
return _llm
def set_llm(provider: LLMProvider | None) -> None:
"""Test hook."""
global _llm
_llm = provider