Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
"""Anthropic provider: structured output via forced tool-use (the response
|
||||
schema becomes the tool's input_schema, so the model can only "call" it with
|
||||
arguments matching the shape we asked for), with a bounded repair loop for
|
||||
the rare malformed response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from anthropic import AsyncAnthropic
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from app.analysis.llm.base import LLMResponseError
|
||||
from app.core.config import Settings
|
||||
|
||||
_TOOL_NAME = "emit_result"
|
||||
|
||||
|
||||
class AnthropicLLMProvider:
|
||||
provider_name = "anthropic"
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
self._client = AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
async def generate_structured[T: BaseModel](
|
||||
self, system_prompt: str, user_prompt: str, response_model: type[T]
|
||||
) -> T:
|
||||
tools = [
|
||||
{
|
||||
"name": _TOOL_NAME,
|
||||
"description": f"Emit the result matching the {response_model.__name__} schema.",
|
||||
"input_schema": response_model.model_json_schema(),
|
||||
}
|
||||
]
|
||||
|
||||
last_error: Exception | None = None
|
||||
messages: list[dict] = [{"role": "user", "content": user_prompt}]
|
||||
|
||||
for attempt in range(self._settings.llm_max_retries + 1):
|
||||
if attempt > 0 and last_error is not None:
|
||||
messages = [
|
||||
*messages,
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Your previous response did not match the required schema: "
|
||||
f"{last_error}. Please try again."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
response = await self._client.messages.create(
|
||||
model=self._settings.anthropic_model,
|
||||
max_tokens=self._settings.llm_max_tokens_per_request,
|
||||
system=system_prompt,
|
||||
tools=tools,
|
||||
tool_choice={"type": "tool", "name": _TOOL_NAME},
|
||||
messages=messages,
|
||||
)
|
||||
tool_use = next((b for b in response.content if b.type == "tool_use"), None)
|
||||
if tool_use is None:
|
||||
last_error = ValueError("No tool_use block in the model's response")
|
||||
continue
|
||||
try:
|
||||
return response_model.model_validate(tool_use.input)
|
||||
except ValidationError as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
|
||||
raise LLMResponseError(
|
||||
f"Anthropic provider failed to produce a valid {response_model.__name__} after "
|
||||
f"{self._settings.llm_max_retries + 1} attempt(s): {last_error}"
|
||||
)
|
||||
|
||||
async def generate_text(self, system_prompt: str, user_prompt: str) -> str:
|
||||
response = await self._client.messages.create(
|
||||
model=self._settings.anthropic_model,
|
||||
max_tokens=self._settings.llm_max_tokens_per_request,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
)
|
||||
return "\n".join(block.text for block in response.content if block.type == "text")
|
||||
Reference in New Issue
Block a user