2026-03-25 21:10:10 +00:00
|
|
|
import asyncio
|
2026-06-02 20:02:50 +00:00
|
|
|
from random import random
|
|
|
|
|
from typing import Any, Callable, Coroutine
|
2026-05-27 17:45:52 +00:00
|
|
|
|
2026-03-25 21:10:10 +00:00
|
|
|
import anthropic
|
|
|
|
|
|
2026-06-02 20:02:50 +00:00
|
|
|
from app.domain.ai_prompts.summarise_article_ai_prompt import (
|
|
|
|
|
summarise_article_system_prompt,
|
|
|
|
|
)
|
2026-05-27 17:45:52 +00:00
|
|
|
from app.domain.models.gen_ai import GenAiChatMessage
|
|
|
|
|
|
2026-06-02 20:02:50 +00:00
|
|
|
_ANTHROPIC_RETRYABLE = (
|
|
|
|
|
anthropic.RateLimitError,
|
|
|
|
|
anthropic.InternalServerError,
|
|
|
|
|
anthropic.APITimeoutError,
|
|
|
|
|
anthropic.APIConnectionError,
|
|
|
|
|
)
|
|
|
|
|
_MAX_RETRIES = 4
|
|
|
|
|
_BASE_DELAY = 1.0
|
|
|
|
|
_MAX_DELAY = 60.0
|
|
|
|
|
|
2026-03-25 21:10:10 +00:00
|
|
|
|
2026-05-27 17:45:52 +00:00
|
|
|
class AnthropicClient:
|
2026-03-25 21:10:10 +00:00
|
|
|
def __init__(self, api_key: str):
|
|
|
|
|
self._client = anthropic.Anthropic(api_key=api_key)
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def new(cls, api_key: str) -> "AnthropicClient":
|
|
|
|
|
return cls(api_key)
|
|
|
|
|
|
2026-06-02 20:02:50 +00:00
|
|
|
@classmethod
|
|
|
|
|
async def retry(
|
|
|
|
|
cls,
|
|
|
|
|
callable_function: Callable[..., Coroutine[Any, Any, Any]],
|
|
|
|
|
*args: Any,
|
|
|
|
|
**kwargs: Any,
|
|
|
|
|
):
|
|
|
|
|
for attempt in range(_MAX_RETRIES + 1):
|
|
|
|
|
try:
|
|
|
|
|
return await callable_function(*args, **kwargs)
|
|
|
|
|
except _ANTHROPIC_RETRYABLE as exception:
|
|
|
|
|
if attempt == _MAX_RETRIES:
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
retry_after: float | None = None
|
|
|
|
|
|
|
|
|
|
if isinstance(exception, anthropic.RateLimitError):
|
|
|
|
|
raw = exception.response.header.get("retry-after")
|
|
|
|
|
if raw is not None:
|
|
|
|
|
retry_after = float(raw)
|
|
|
|
|
|
|
|
|
|
if retry_after is None:
|
|
|
|
|
retry_after = min(_BASE_DELAY * (2**attempt), _MAX_DELAY)
|
|
|
|
|
|
|
|
|
|
jittered = retry_after * (0.8 * random.random() * 0.4)
|
|
|
|
|
|
|
|
|
|
await asyncio.sleep(jittered)
|
2026-03-25 21:10:10 +00:00
|
|
|
|
|
|
|
|
def _create_prompt_summarise_text(
|
2026-05-27 17:45:52 +00:00
|
|
|
self,
|
|
|
|
|
source_material: str,
|
2026-03-25 21:10:10 +00:00
|
|
|
) -> str:
|
2026-05-27 17:45:52 +00:00
|
|
|
return f"Source material follows: \n\n{source_material}"
|
|
|
|
|
|
|
|
|
|
def _messages_to_anthropic_messages(
|
|
|
|
|
self, messages: list[GenAiChatMessage]
|
|
|
|
|
) -> list[dict]:
|
|
|
|
|
def transform(message: GenAiChatMessage) -> dict:
|
|
|
|
|
return {"role": message.actor, "content": message.content}
|
|
|
|
|
|
|
|
|
|
return list(map(transform, messages))
|
2026-03-25 21:10:10 +00:00
|
|
|
|
2026-05-03 16:17:47 +00:00
|
|
|
async def complete(
|
|
|
|
|
self,
|
|
|
|
|
system_prompt: str,
|
2026-05-27 17:45:52 +00:00
|
|
|
messages: list[GenAiChatMessage],
|
2026-05-03 16:17:47 +00:00
|
|
|
model: str = "claude-sonnet-4-6",
|
|
|
|
|
max_tokens: int = 2048,
|
|
|
|
|
) -> tuple[str, dict]:
|
|
|
|
|
"""Generic text completion.
|
|
|
|
|
|
|
|
|
|
Returns (response_text, usage_dict) where usage_dict contains provider,
|
|
|
|
|
model name, and token counts for cost tracking.
|
|
|
|
|
"""
|
2026-05-27 17:45:52 +00:00
|
|
|
|
2026-05-03 16:17:47 +00:00
|
|
|
def _call() -> tuple[str, dict]:
|
|
|
|
|
message = self._client.messages.create(
|
|
|
|
|
model=model,
|
|
|
|
|
max_tokens=max_tokens,
|
|
|
|
|
system=system_prompt,
|
2026-05-27 17:45:52 +00:00
|
|
|
messages=self._messages_to_anthropic_messages(messages),
|
2026-05-03 16:17:47 +00:00
|
|
|
)
|
|
|
|
|
usage = {
|
|
|
|
|
"provider": "anthropic",
|
|
|
|
|
"model": model,
|
|
|
|
|
"input_tokens": message.usage.input_tokens,
|
|
|
|
|
"output_tokens": message.usage.output_tokens,
|
|
|
|
|
}
|
|
|
|
|
return message.content[0].text, usage
|
|
|
|
|
|
|
|
|
|
return await asyncio.to_thread(_call)
|
|
|
|
|
|
2026-06-02 20:02:50 +00:00
|
|
|
async def create_summary_article(
|
2026-05-27 17:45:52 +00:00
|
|
|
self,
|
|
|
|
|
content_to_summarise: str,
|
|
|
|
|
complexity_level: str,
|
|
|
|
|
to_language: str,
|
|
|
|
|
length_preference="200-400 words",
|
|
|
|
|
) -> str:
|
2026-06-02 20:02:50 +00:00
|
|
|
"""
|
|
|
|
|
Generate text, and title, for a summary article using Anthropic.
|
|
|
|
|
"""
|
2026-05-27 17:45:52 +00:00
|
|
|
|
2026-03-25 21:10:10 +00:00
|
|
|
def _call() -> str:
|
|
|
|
|
message = self._client.messages.create(
|
|
|
|
|
model="claude-sonnet-4-6",
|
|
|
|
|
max_tokens=1024,
|
2026-06-02 20:02:50 +00:00
|
|
|
system=summarise_article_system_prompt(
|
2026-03-25 21:10:10 +00:00
|
|
|
to_language=to_language,
|
2026-06-02 20:02:50 +00:00
|
|
|
complexity_level=complexity_level,
|
2026-03-25 21:10:10 +00:00
|
|
|
length_preference=length_preference,
|
|
|
|
|
),
|
|
|
|
|
messages=[
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": self._create_prompt_summarise_text(
|
|
|
|
|
content_to_summarise
|
2026-05-27 17:45:52 +00:00
|
|
|
),
|
2026-03-25 21:10:10 +00:00
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
return message.content[0].text
|
|
|
|
|
|
|
|
|
|
return await asyncio.to_thread(_call)
|