Replace single report type with composable analysis: depth levels (basic/standard/full) + focus area multi-select (psychology, business, marketing, content, audience, sentiment). Multi-step inline keyboard flow guides users through selection. - i18n: English + Russian, auto-detect from Telegram, /lang override - AI providers: Anthropic + OpenRouter via AIClient abstraction - Telegram Stars payments with per-depth pricing and free trial - SQLite (aiosqlite) for users, analyses, payments tracking - User middleware for auto-registration and language detection - Report persistence: save .md locally, offer file download - New commands: /features, /prices, /lang - Composable prompt system: depth modifiers + focus area fragments
106 lines
3 KiB
Python
106 lines
3 KiB
Python
import json
|
|
import logging
|
|
from abc import ABC, abstractmethod
|
|
from collections.abc import Callable, Coroutine
|
|
from typing import Any
|
|
|
|
import anthropic
|
|
import httpx
|
|
|
|
from bot.config import settings
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
ProgressCallback = Callable[[str], Coroutine[Any, Any, None]]
|
|
|
|
|
|
class AIClient(ABC):
|
|
@abstractmethod
|
|
async def complete(
|
|
self,
|
|
system: str,
|
|
user: str,
|
|
max_tokens: int,
|
|
on_progress: ProgressCallback | None = None,
|
|
) -> str: ...
|
|
|
|
|
|
class AnthropicClient(AIClient):
|
|
def __init__(self, model_id: str) -> None:
|
|
self.model_id = model_id
|
|
self.client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
|
|
|
async def complete(
|
|
self,
|
|
system: str,
|
|
user: str,
|
|
max_tokens: int,
|
|
on_progress: ProgressCallback | None = None,
|
|
) -> str:
|
|
kwargs: dict[str, Any] = {
|
|
"model": self.model_id,
|
|
"max_tokens": max_tokens,
|
|
"system": system,
|
|
"messages": [{"role": "user", "content": user}],
|
|
}
|
|
if "opus-4-6" in self.model_id:
|
|
kwargs["thinking"] = {"type": "adaptive"}
|
|
|
|
async with self.client.messages.stream(**kwargs) as stream:
|
|
response = await stream.get_final_message()
|
|
|
|
return "".join(
|
|
block.text for block in response.content if block.type == "text"
|
|
)
|
|
|
|
|
|
class OpenRouterClient(AIClient):
|
|
BASE_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
|
|
def __init__(self, model_id: str) -> None:
|
|
self.model_id = model_id
|
|
|
|
async def complete(
|
|
self,
|
|
system: str,
|
|
user: str,
|
|
max_tokens: int,
|
|
on_progress: ProgressCallback | None = None,
|
|
) -> str:
|
|
headers = {
|
|
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
payload = {
|
|
"model": self.model_id,
|
|
"max_tokens": max_tokens,
|
|
"messages": [
|
|
{"role": "system", "content": system},
|
|
{"role": "user", "content": user},
|
|
],
|
|
"stream": True,
|
|
}
|
|
|
|
collected = []
|
|
async with httpx.AsyncClient(timeout=300) as client:
|
|
async with client.stream("POST", self.BASE_URL, headers=headers, json=payload) as resp:
|
|
resp.raise_for_status()
|
|
async for line in resp.aiter_lines():
|
|
if not line.startswith("data: "):
|
|
continue
|
|
data = line[6:]
|
|
if data == "[DONE]":
|
|
break
|
|
chunk = json.loads(data)
|
|
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
|
content = delta.get("content", "")
|
|
if content:
|
|
collected.append(content)
|
|
|
|
return "".join(collected)
|
|
|
|
|
|
def get_ai_client(model_id: str) -> AIClient:
|
|
if "/" in model_id:
|
|
return OpenRouterClient(model_id)
|
|
return AnthropicClient(model_id)
|