diff --git a/.github/workflows/test-python-app-chat.yaml b/.github/workflows/test-python-app-chat.yaml new file mode 100644 index 0000000..34b99d3 --- /dev/null +++ b/.github/workflows/test-python-app-chat.yaml @@ -0,0 +1,33 @@ +name: Python Tests +on: + # push: + # paths: ['apps/chat/**'] + pull_request: + paths: ['apps/chat/**'] +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/chat + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install pytest pytest-asyncio pytest-cov mypy ruff + - name: Run tests + env: + OTEL_SDK_DISABLED: true + run: | + export PYTHONPATH=. + pytest --cov=app tests/ + - name: Type check + run: mypy app --ignore-missing-imports + - name: Lint + run: ruff check app \ No newline at end of file diff --git a/apps/chat/app/api.py b/apps/chat/app/api.py index a872d72..bc86876 100644 --- a/apps/chat/app/api.py +++ b/apps/chat/app/api.py @@ -1,7 +1,7 @@ -from fastapi import APIRouter, HTTPException, status, Depends, Request +from fastapi import APIRouter, HTTPException, Depends, Request from datetime import datetime from .schemas import ( - ChatRequest, ChatResponse, Message, + ChatRequest, ChatResponse, CreateProjectRequest, ProjectInfo, ) from .models import ChatHistory, StoredChatMessage @@ -63,6 +63,7 @@ async def chat_endpoint( except Exception as e: log.bind(event="chat_api_error", error=str(e)).error("Standard chat request failed") traceback.print_exc() + # raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="...") raise HTTPException(status_code=500, detail="AI сервис недоступен") @api_router.post("/projects", response_model=ProjectInfo) diff --git a/apps/chat/app/behavior/models.py b/apps/chat/app/behavior/models.py index dbd7b0e..4900bab 100644 --- a/apps/chat/app/behavior/models.py +++ b/apps/chat/app/behavior/models.py @@ -4,43 +4,41 @@ class TaskSchema(BaseModel): """Single task definition executed by an agent.""" - - description: str = Field(..., example="Collect articles about AI") + + description: str = Field(..., examples=["Collect articles about AI"]) expected_output: Optional[str] = Field( None, - alias="expected_output", - example="List of article URLs", + alias="expected_output", + examples=["List of article URLs"] ) context: Optional[List[str]] = Field( default_factory=list, - example=["Use only academic sources"], + examples=[["Use only academic sources"]] ) - agent: Optional[str] = Field(None, example="researcher") - + agent: Optional[str] = Field(None, examples=["researcher"]) + model_config = ConfigDict(populate_by_name=True) - class AgentSchema(BaseModel): """CrewAI-style agent configuration.""" - - role: str = Field(..., example="researcher") - goal: Optional[str] = Field(None, example="Provide an overview of AI trends") - backstory: Optional[str] = Field(None, example="PhD in computer science") - tools: List[str] = Field(default_factory=list, example=["browser"]) - allow_delegation: bool = Field(False, alias="allow_delegation", example=True) + + role: str = Field(..., examples=["researcher"]) + goal: Optional[str] = Field(None, examples=["Provide an overview of AI trends"]) + backstory: Optional[str] = Field(None, examples=["PhD in computer science"]) + tools: List[str] = Field(default_factory=list, examples=[["browser"]]) + allow_delegation: bool = Field(False, alias="allow_delegation", examples=[True]) tasks: List[TaskSchema] = Field(default_factory=list) - + model_config = ConfigDict(populate_by_name=True) - class BehaviorDefinition(BaseModel): """Root object describing agent behaviors loaded from Notion.""" - + agents: List[AgentSchema] = Field( default_factory=list, - example=[{"role": "researcher", "goal": "Find info"}], + examples=[[{"role": "researcher", "goal": "Find info"}]] ) tasks: List[TaskSchema] = Field(default_factory=list) - process: Optional[str] = Field("sequential", example="sequential") - - model_config = ConfigDict(populate_by_name=True) + process: Optional[str] = Field("sequential", examples=["sequential"]) + + model_config = ConfigDict(populate_by_name=True) \ No newline at end of file diff --git a/apps/chat/app/core/llm_client.py b/apps/chat/app/core/llm_client.py index 88d4344..2736f41 100644 --- a/apps/chat/app/core/llm_client.py +++ b/apps/chat/app/core/llm_client.py @@ -92,7 +92,7 @@ async def chat_completion( span.set_attribute("llm.request_size", request_size) # Создаем отдельный span для HTTP запроса - with tracer.start_as_current_span("http_request") as http_span: + with tracer.start_as_current_span("http_request"): async with httpx.AsyncClient() as client: response = await client.post( self.api_url, @@ -154,7 +154,11 @@ async def chat_completion( "response_body": e.response.text[:500] # Первые 500 символов } ) - raise + # Возвращаем error response вместо raise + return { + "error": f"HTTP {e.response.status_code}: {str(e)}", + "choices": [{"message": {"content": f"HTTP ошибка: {e.response.status_code}"}}] + } except Exception as e: # Обработка других ошибок @@ -177,8 +181,11 @@ async def chat_completion( "exception.message": str(e) } ) -# apps/chat/app/core/llm_client.py -# В конец класса OpenRouterClient добавь: + # Возвращаем error response вместо raise + return { + "error": str(e), + "choices": [{"message": {"content": f"Ошибка: {str(e)}"}}] + } async def generate_reply( self, @@ -230,6 +237,10 @@ async def generate_reply( model=self.default_model ) + # Проверяем на ошибки + if "error" in response: + return f"Ошибка API: {response['error']}" + # Извлекаем текст ответа return response["choices"][0]["message"]["content"] diff --git a/apps/chat/app/core/project_memory.py b/apps/chat/app/core/project_memory.py index 4a62ecf..35b2c63 100644 --- a/apps/chat/app/core/project_memory.py +++ b/apps/chat/app/core/project_memory.py @@ -1,5 +1,5 @@ from typing import Dict, List, Optional -from app.schemas import ProjectInfo, CreateProjectRequest +from app.schemas import ProjectInfo from app.models import ChatHistory, StoredChatMessage from uuid import uuid4 diff --git a/apps/chat/app/integrations/behavior_manager.py b/apps/chat/app/integrations/behavior_manager.py index fe2e83e..2417d50 100644 --- a/apps/chat/app/integrations/behavior_manager.py +++ b/apps/chat/app/integrations/behavior_manager.py @@ -1,3 +1,4 @@ +from typing import Optional, Dict, Any import yaml from pydantic import ValidationError @@ -10,27 +11,31 @@ class BehaviorManager: def __init__(self, notion_client, page_id: str): self.notion_client = notion_client self.page_id = page_id - self.behavior: BehaviorDefinition | None = None + self.behavior: Optional[BehaviorDefinition] = None async def refresh(self) -> None: log = enrich_context(event="behavior_refresh", page_id=self.page_id) data = await self.notion_client.fetch_page(self.page_id) log.info("Behavior page retrieved") + try: # Expect first child to be a code block with YAML for block in data.get("results", []): if block.get("type") == "code": text = block["code"].get("rich_text", []) content = "".join(t.get("plain_text", "") for t in text) - raw = yaml.safe_load(content) or {} + raw: Dict[str, Any] = yaml.safe_load(content) or {} self.behavior = BehaviorDefinition.model_validate(raw) - log.bind(event="behavior_parsed").debug(self.behavior.model_dump()) + + # Правильная типизация для логирования + behavior_dict = self.behavior.model_dump() + log.bind(event="behavior_parsed").debug(f"Parsed behavior: {behavior_dict}") log.bind(event="behavior_loaded").info("Behavior updated") return log.bind(event="behavior_not_found").warning("No YAML code block found") except ValidationError as e: - log.bind(event="behavior_validation_error", errors=e.errors()).error("Behavior validation failed") + log.bind(event="behavior_validation_error", errors=str(e.errors())).error("Behavior validation failed") raise except Exception as e: log.bind(event="behavior_parse_error", error=str(e)).error("Failed to parse behavior") - raise + raise \ No newline at end of file diff --git a/apps/chat/app/logger.py b/apps/chat/app/logger.py index 1f85b8a..341d6ac 100644 --- a/apps/chat/app/logger.py +++ b/apps/chat/app/logger.py @@ -1,6 +1,5 @@ -import logging from contextvars import ContextVar -from typing import Any, Dict, Optional +from typing import Any, Dict, cast from opentelemetry import trace import structlog @@ -30,21 +29,23 @@ def _add_trace_context(self, event_dict: Dict[str, Any]) -> Dict[str, Any]: def bind(self, **kwargs) -> 'EnrichedLogger': """Добавляет контекст к логгеру.""" - return EnrichedLogger(self.logger.bind(**kwargs)) + # Приводим к нужному типу для MyPy + bound_logger = cast(structlog.BoundLogger, self.logger.bind(**kwargs)) + return EnrichedLogger(bound_logger) - def info(self, msg: str, **kwargs): + def info(self, msg: str, **kwargs) -> None: kwargs = self._add_trace_context(kwargs) self.logger.info(msg, **kwargs) - def error(self, msg: str, **kwargs): + def error(self, msg: str, **kwargs) -> None: kwargs = self._add_trace_context(kwargs) self.logger.error(msg, **kwargs) - def warning(self, msg: str, **kwargs): + def warning(self, msg: str, **kwargs) -> None: kwargs = self._add_trace_context(kwargs) self.logger.warning(msg, **kwargs) - def debug(self, msg: str, **kwargs): + def debug(self, msg: str, **kwargs) -> None: kwargs = self._add_trace_context(kwargs) self.logger.debug(msg, **kwargs) @@ -74,9 +75,12 @@ def enrich_context(**kwargs) -> EnrichedLogger: Создает логгер с обогащенным контекстом. Совместимо с существующим кодом. """ - return EnrichedLogger(base_logger.bind(**kwargs)) + # Приводим к нужному типу для MyPy + bound_logger = cast(structlog.BoundLogger, base_logger.bind(**kwargs)) + return EnrichedLogger(bound_logger) -def set_request_context(**kwargs): + +def set_request_context(**kwargs) -> None: """ Устанавливает контекст для всего request. Используется в middleware или в начале обработки запроса. diff --git a/apps/chat/app/models/chat.py b/apps/chat/app/models/chat.py index 2402bc3..6a3fc8e 100644 --- a/apps/chat/app/models/chat.py +++ b/apps/chat/app/models/chat.py @@ -1,11 +1,10 @@ -# apps/chat/app/models/chat.py from pydantic import BaseModel, Field, ConfigDict from typing import List, Optional from uuid import uuid4 from datetime import datetime -from ..schemas import Role - +# Импортируем Role из schemas, избегаем дублирования +from ..schemas.chat import Role class StoredChatMessage(BaseModel): model_config = ConfigDict(populate_by_name=True) @@ -16,54 +15,12 @@ class StoredChatMessage(BaseModel): span_id: Optional[str] = None timestamp: datetime = Field(default_factory=datetime.utcnow) - class ChatHistory(BaseModel): model_config = ConfigDict(populate_by_name=True) id: str = Field(default_factory=lambda: str(uuid4())) - project_id: str + project_id: str = Field(..., examples=["proj-123"]) messages: List[StoredChatMessage] trace_id: Optional[str] = None span_id: Optional[str] = None - timestamp: datetime = Field(default_factory=datetime.utcnow) - - -# apps/chat/app/schemas/chat.py -from pydantic import BaseModel, Field, ConfigDict -from enum import Enum -from typing import List, Optional - - -class Role(str, Enum): - user = "user" - assistant = "assistant" - system = "system" - - -class Message(BaseModel): - role: Role = Field(..., example="user") - content: str = Field(..., example="Hello") - - -class ChatRequest(BaseModel): - model_config = ConfigDict(populate_by_name=True) - - messages: List[Message] = Field(..., example=[{"role": "user", "content": "Hi"}]) - user_api_key: Optional[str] = Field(None, alias="userApiKey", example="sk-...") - - -class ChatResponse(BaseModel): - reply: str = Field(..., example="Hello from AI") - - -# apps/chat/app/schemas/projects.py -from pydantic import BaseModel, Field - - -class CreateProjectRequest(BaseModel): - name: str = Field(..., example="My Project") - - -class ProjectInfo(BaseModel): - id: str = Field(..., example="project-123") - name: str = Field(..., example="Demo Project") \ No newline at end of file + timestamp: datetime = Field(default_factory=datetime.utcnow) \ No newline at end of file diff --git a/apps/chat/app/observability/tracing.py b/apps/chat/app/observability/tracing.py index 2ec6186..2d453f4 100644 --- a/apps/chat/app/observability/tracing.py +++ b/apps/chat/app/observability/tracing.py @@ -1,5 +1,6 @@ import logging import os +from typing import Dict, Any from opentelemetry import trace from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from opentelemetry.sdk.resources import Resource @@ -12,6 +13,19 @@ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter + +def server_request_hook(span, scope: Dict[str, Any]) -> None: + """Кастомный hook для обогащения spans атрибутами.""" + if path := scope.get("path"): + span.set_attribute("http.url.path", path) + + if query_string := scope.get("query_string"): + span.set_attribute("http.url.query", query_string.decode()) + + if event_type := scope.get("type"): + span.set_attribute("asgi.event_type", event_type) + + def setup_tracing(app) -> None: """ Настройка OpenTelemetry для traces и logs согласно CNCF практикам. @@ -93,8 +107,9 @@ def setup_tracing(app) -> None: # JSON logging для production if os.getenv("ENVIRONMENT") == "production": import json + class JSONFormatter(logging.Formatter): - def format(self, record): + def format(self, record: logging.LogRecord) -> str: log_obj = { "timestamp": self.formatTime(record), "level": record.levelname, @@ -107,8 +122,11 @@ def format(self, record): span = trace.get_current_span() if span.is_recording(): ctx = span.get_span_context() - log_obj["trace_id"] = format(ctx.trace_id, '032x') - log_obj["span_id"] = format(ctx.span_id, '016x') + # Приводим к нужным типам для MyPy + trace_id: int = ctx.trace_id + span_id: int = ctx.span_id + log_obj["trace_id"] = format(trace_id, '032x') + log_obj["span_id"] = format(span_id, '016x') return json.dumps(log_obj) console_handler = logging.StreamHandler() @@ -126,12 +144,7 @@ def format(self, record): FastAPIInstrumentor().instrument_app( app, tracer_provider=provider, - # Кастомные атрибуты - server_request_hook=lambda span, scope: span.set_attributes({ - "http.url.path": scope.get("path"), - "http.url.query": scope.get("query_string", b"").decode(), - "asgi.event_type": scope.get("type"), - }) + server_request_hook=server_request_hook ) # Log успешной инициализации diff --git a/apps/chat/app/schemas/chat.py b/apps/chat/app/schemas/chat.py index 76e6578..dfb3aae 100644 --- a/apps/chat/app/schemas/chat.py +++ b/apps/chat/app/schemas/chat.py @@ -1,33 +1,21 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict from enum import Enum from typing import List, Optional - -# ---------------------------------------- -# 📌 Роли сообщений -# ---------------------------------------- class Role(str, Enum): user = "user" assistant = "assistant" system = "system" -# ---------------------------------------- -# 📌 Базовое сообщение -# ---------------------------------------- class Message(BaseModel): - role: Role = Field(..., example="user") - content: str = Field(..., example="Hello") + role: Role = Field(..., examples=["user"]) + content: str = Field(..., examples=["Hello"]) -# ---------------------------------------- -# 📌 Структура запроса и ответа чата -# ---------------------------------------- class ChatRequest(BaseModel): - messages: List[Message] = Field(..., example=[{"role": "user", "content": "Hi"}]) - user_api_key: Optional[str] = Field(None, alias="userApiKey", example="sk-...") - - class Config: - allow_population_by_field_name = True + model_config = ConfigDict(populate_by_name=True) + + messages: List[Message] = Field(..., examples=[[{"role": "user", "content": "Hi"}]]) + user_api_key: Optional[str] = Field(None, alias="userApiKey", examples=["sk-..."]) class ChatResponse(BaseModel): - reply: str = Field(..., example="Hello from AI") - + reply: str = Field(..., examples=["Hello from AI"]) \ No newline at end of file diff --git a/apps/chat/app/schemas/projects.py b/apps/chat/app/schemas/projects.py index a688135..7a0cdc0 100644 --- a/apps/chat/app/schemas/projects.py +++ b/apps/chat/app/schemas/projects.py @@ -1,8 +1,8 @@ from pydantic import BaseModel, Field class CreateProjectRequest(BaseModel): - name: str = Field(..., example="My Project") + name: str = Field(..., examples=["My Project"]) class ProjectInfo(BaseModel): - id: str = Field(..., example="project-123") - name: str = Field(..., example="Demo Project") + id: str = Field(..., examples=["project-123"]) + name: str = Field(..., examples=["Demo Project"]) \ No newline at end of file diff --git a/apps/chat/app/services/chat_service.py b/apps/chat/app/services/chat_service.py index 6b1b3ba..f6b9beb 100644 --- a/apps/chat/app/services/chat_service.py +++ b/apps/chat/app/services/chat_service.py @@ -10,7 +10,7 @@ class ChatService: ChatService инкапсулирует бизнес-логику чата. """ - def __init__(self, llm_client: OpenRouterClient = None): + def __init__(self, llm_client: Optional[OpenRouterClient] = None): self.llm_client = llm_client or OpenRouterClient() enrich_context(event="chat_service_init").info("Chat service initialized") diff --git a/apps/chat/requirements.txt b/apps/chat/requirements.txt index 5f9ac0d..082614c 100644 --- a/apps/chat/requirements.txt +++ b/apps/chat/requirements.txt @@ -32,4 +32,6 @@ notion-client==2.2.1 # Utils python-dotenv==1.0.1 -tenacity==9.0.0 \ No newline at end of file +tenacity==9.0.0 +types-PyYAML==6.0.12 +types-requests==2.31.0.20240406 \ No newline at end of file diff --git a/apps/chat/tests/conftest.py b/apps/chat/tests/conftest.py new file mode 100644 index 0000000..d2d2c2a --- /dev/null +++ b/apps/chat/tests/conftest.py @@ -0,0 +1,19 @@ +import pytest +from unittest.mock import AsyncMock +from fastapi.testclient import TestClient +from app.main import create_app + +@pytest.fixture +def client(): + app = create_app() + return TestClient(app) + +@pytest.fixture +def mock_openrouter(monkeypatch): + async def mock_generate_reply(*args, **kwargs): + return "Mocked response from AI" + + monkeypatch.setattr( + "app.core.llm_client.OpenRouterClient.generate_reply", + mock_generate_reply + ) \ No newline at end of file diff --git a/apps/chat/tests/test_api.py b/apps/chat/tests/test_api.py new file mode 100644 index 0000000..5d7d2af --- /dev/null +++ b/apps/chat/tests/test_api.py @@ -0,0 +1,16 @@ +from fastapi.testclient import TestClient +from app.main import create_app + +# client = TestClient(create_app()) + +def test_health_endpoint(client): + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + +def test_chat_endpoint(client, mock_openrouter): + response = client.post("/api/v1/chat", json={ + "messages": [{"role": "user", "content": "test"}] + }) + assert response.status_code == 200 + assert "reply" in response.json() \ No newline at end of file diff --git a/apps/chat/tests/test_chat_service.py b/apps/chat/tests/test_chat_service.py new file mode 100644 index 0000000..d67e9c8 --- /dev/null +++ b/apps/chat/tests/test_chat_service.py @@ -0,0 +1,14 @@ +import pytest +from unittest.mock import AsyncMock +from app.services.chat_service import ChatService +from app.schemas import Message + +@pytest.mark.asyncio +async def test_chat_service_reply(): + service = ChatService() + # Mock LLM client + service.llm_client.generate_reply = AsyncMock(return_value="Hello!") + + messages = [Message(role="user", content="test")] + reply = await service.get_ai_reply(messages) + assert reply == "Hello!" \ No newline at end of file diff --git a/apps/chat/tests/test_llm_client.py b/apps/chat/tests/test_llm_client.py new file mode 100644 index 0000000..b9a2e7a --- /dev/null +++ b/apps/chat/tests/test_llm_client.py @@ -0,0 +1,5 @@ +from app.core.llm_client import OpenRouterClient + +def test_openrouter_client_init(): + client = OpenRouterClient() + assert client.api_url == "https://openrouter.ai/api/v1/chat/completions" \ No newline at end of file diff --git a/apps/chat/tests/test_schemas.py b/apps/chat/tests/test_schemas.py new file mode 100644 index 0000000..b248e59 --- /dev/null +++ b/apps/chat/tests/test_schemas.py @@ -0,0 +1,6 @@ +from app.behavior import BehaviorDefinition + +def test_behavior_definition(): + data = {"agents": [], "tasks": []} + behavior = BehaviorDefinition(**data) + assert behavior.agents == [] \ No newline at end of file