refactor: rename platform/ → sdk/ to avoid stdlib conflict

platform/ shadowed Python's stdlib platform module, breaking
aiogram/aiohttp/multidict at import time. Renamed to sdk/ and
updated all imports across core/, tests/, and adapter/telegram/.
This commit is contained in:
Mikhail Putilovskij 2026-03-31 21:57:23 +03:00
parent c979f96c3c
commit 41660fe84a
15 changed files with 1727 additions and 11 deletions

0
sdk/__init__.py Normal file
View file

97
sdk/interface.py Normal file
View file

@ -0,0 +1,97 @@
# platform/interface.py
from __future__ import annotations
from datetime import datetime
from typing import Any, AsyncIterator, Literal, Protocol
from pydantic import BaseModel
class User(BaseModel):
user_id: str
external_id: str
platform: str
display_name: str | None = None
created_at: datetime
is_new: bool = False
class Attachment(BaseModel):
url: str
mime_type: str
size: int | None = None
filename: str | None = None
class MessageResponse(BaseModel):
message_id: str
response: str
tokens_used: int
finished: bool
class MessageChunk(BaseModel):
"""Один кусок стримингового ответа. При sync-режиме — единственный чанк с finished=True."""
message_id: str
delta: str
finished: bool
tokens_used: int = 0
class UserSettings(BaseModel):
skills: dict[str, bool] = {}
connectors: dict[str, dict] = {}
soul: dict[str, str] = {} # свободные поля: name, instructions и т.п. — без пресетов стилей
safety: dict[str, bool] = {}
plan: dict[str, Any] = {}
class AgentEvent(BaseModel):
"""Webhook-уведомление от платформы — агент закончил долгую задачу."""
event_id: str
user_id: str
chat_id: str
event_type: Literal["task_done", "task_error", "task_progress"]
payload: dict[str, Any] = {}
class PlatformError(Exception):
def __init__(self, message: str, code: str = "PLATFORM_ERROR"):
super().__init__(message)
self.code = code
class PlatformClient(Protocol):
async def get_or_create_user(
self,
external_id: str,
platform: str,
display_name: str | None = None,
) -> User: ...
# Sync — используем сейчас
async def send_message(
self,
user_id: str,
chat_id: str,
text: str,
attachments: list[Attachment] | None = None,
) -> MessageResponse: ...
# Streaming — дверь открыта, мок отдаёт один чанк
async def stream_message(
self,
user_id: str,
chat_id: str,
text: str,
attachments: list[Attachment] | None = None,
) -> AsyncIterator[MessageChunk]: ...
async def get_settings(self, user_id: str) -> UserSettings: ...
async def update_settings(self, user_id: str, action: Any) -> None: ...
class WebhookReceiver(Protocol):
"""Регистрируется в боте. Платформа зовёт нас когда агент закончил долгую задачу."""
async def on_agent_event(self, event: AgentEvent) -> None: ...

231
sdk/mock.py Normal file
View file

@ -0,0 +1,231 @@
# platform/mock.py
from __future__ import annotations
import asyncio
import random
import uuid
from datetime import UTC, datetime
from typing import Any, AsyncIterator, Literal
import structlog
from sdk.interface import (
AgentEvent,
Attachment,
MessageChunk,
MessageResponse,
User,
UserSettings,
WebhookReceiver,
)
logger = structlog.get_logger(__name__)
class MockPlatformClient:
"""
Заглушка SDK платформы Lambda.
Реализует PlatformClient Protocol. При подключении реального SDK
заменяется только этот файл core/ и адаптеры не трогаются.
attachment_mode симулирует разные варианты передачи файлов:
"url" платформа получает URL, скачивает сама (текущий план)
"binary" бинарные данные в теле (резерв)
"s3" pre-signed S3 URL (резерв)
Webhook: зарегистрируй WebhookReceiver через register_webhook_receiver(),
вызови simulate_agent_event() чтобы имитировать входящее уведомление.
"""
def __init__(
self,
attachment_mode: Literal["url", "binary", "s3"] = "url",
) -> None:
self.attachment_mode = attachment_mode
self._users: dict[str, dict] = {}
self._messages: dict[str, list] = {} # "{user_id}:{chat_id}" → messages
self._settings: dict[str, dict] = {}
self._webhook_receiver: WebhookReceiver | None = None
logger.info("MockPlatformClient initialized", attachment_mode=attachment_mode)
# ------------------------------------------------------------------ users
async def get_or_create_user(
self,
external_id: str,
platform: str,
display_name: str | None = None,
) -> User:
await self._latency()
key = f"{platform}:{external_id}"
is_new = key not in self._users
if is_new:
self._users[key] = {
"user_id": f"usr-{platform}-{external_id}",
"external_id": external_id,
"platform": platform,
"display_name": display_name,
"created_at": "2025-01-01T00:00:00Z",
"is_new": True,
}
data = {**self._users[key], "is_new": is_new}
return User(**data)
# --------------------------------------------------------------- messages
async def send_message(
self,
user_id: str,
chat_id: str,
text: str,
attachments: list[Attachment] | None = None,
) -> MessageResponse:
await self._latency(200, 600)
message_id, response, tokens = self._build_response(user_id, chat_id, text, attachments)
logger.info("send_message", user_id=user_id, chat_id=chat_id, message_id=message_id)
return MessageResponse(
message_id=message_id,
response=response,
tokens_used=tokens,
finished=True,
)
async def stream_message(
self,
user_id: str,
chat_id: str,
text: str,
attachments: list[Attachment] | None = None,
) -> AsyncIterator[MessageChunk]:
"""
Сейчас: один чанк с полным ответом (sync под капотом).
При реальном SDK: заменить на SSE/WebSocket итератор в platform/mock.py.
Адаптеры переписывать не нужно.
"""
await self._latency(200, 600)
message_id, response, tokens = self._build_response(user_id, chat_id, text, attachments)
logger.info("stream_message", user_id=user_id, chat_id=chat_id, message_id=message_id)
async def _gen() -> AsyncIterator[MessageChunk]:
yield MessageChunk(
message_id=message_id,
delta=response,
finished=True,
tokens_used=tokens,
)
return _gen()
# --------------------------------------------------------------- settings
async def get_settings(self, user_id: str) -> UserSettings:
await self._latency()
stored = self._settings.get(user_id, {})
return UserSettings(
skills=stored.get("skills", {
"web-search": True,
"fetch-url": True,
"email": False,
"browser": False,
"image-gen": False,
"files": True,
}),
connectors=stored.get("connectors", {}),
soul=stored.get("soul", {"name": "Лямбда", "instructions": ""}),
safety=stored.get("safety", {
"email-send": True,
"file-delete": True,
"social-post": True,
}),
plan=stored.get("plan", {
"name": "Beta",
"tokens_used": 0,
"tokens_limit": 1000,
}),
)
async def update_settings(self, user_id: str, action: Any) -> None:
await self._latency()
settings = self._settings.setdefault(user_id, {})
if action.action == "toggle_skill":
skills = settings.setdefault("skills", {})
skills[action.payload["skill"]] = action.payload.get("enabled", True)
elif action.action == "set_soul":
soul = settings.setdefault("soul", {})
soul[action.payload["field"]] = action.payload["value"]
elif action.action == "set_safety":
safety = settings.setdefault("safety", {})
safety[action.payload["trigger"]] = action.payload.get("enabled", True)
logger.info("Settings updated", user_id=user_id, action=action.action)
# --------------------------------------------------------------- webhooks
def register_webhook_receiver(self, receiver: WebhookReceiver) -> None:
"""Бот регистрирует свой обработчик входящих событий от платформы."""
self._webhook_receiver = receiver
logger.info("WebhookReceiver registered")
async def simulate_agent_event(
self,
user_id: str,
chat_id: str,
event_type: Literal["task_done", "task_error", "task_progress"] = "task_done",
payload: dict | None = None,
) -> None:
"""Имитирует входящий webhook от платформы. Используется в тестах и ручном QA."""
if self._webhook_receiver is None:
logger.warning("simulate_agent_event: no WebhookReceiver registered")
return
event = AgentEvent(
event_id=str(uuid.uuid4()),
user_id=user_id,
chat_id=chat_id,
event_type=event_type,
payload=payload or {"message": "[MOCK] Долгая задача выполнена"},
)
await self._webhook_receiver.on_agent_event(event)
# ------------------------------------------------------------------ utils
def get_stats(self) -> dict:
return {
"total_users": len(self._users),
"total_messages": sum(len(msgs) for msgs in self._messages.values()),
"attachment_mode": self.attachment_mode,
}
def _build_response(
self,
user_id: str,
chat_id: str,
text: str,
attachments: list[Attachment] | None,
) -> tuple[str, str, int]:
key = f"{user_id}:{chat_id}"
if key not in self._messages:
self._messages[key] = []
message_id = str(uuid.uuid4())
preview = text[:50] + ("..." if len(text) > 50 else "")
attachment_note = ""
if attachments:
names = [a.filename or a.mime_type for a in attachments]
attachment_note = f" [вложения: {', '.join(names)}]"
response = f"[MOCK] Ответ на: «{preview}»{attachment_note}"
tokens = len(text.split()) * 2
self._messages[key].append({
"message_id": message_id,
"user_text": text,
"response": response,
"tokens_used": tokens,
"finished": True,
"created_at": datetime.now(UTC).isoformat(),
})
return message_id, response, tokens
async def _latency(self, min_ms: int = 10, max_ms: int = 80) -> None:
await asyncio.sleep(random.randint(min_ms, max_ms) / 1000)