diff --git a/Dockerfile b/Dockerfile index 311306c..36998c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 -FROM ghcr.io/astral-sh/uv:0.8.15 AS uv -FROM python:3.14-slim AS build +FROM ghcr.io/astral-sh/uv:0.8.15@sha256:a5727064a0de127bdb7c9d3c1383f3a9ac307d9f2d8a391edc7896c54289ced0 AS uv +FROM python:3.14-slim@sha256:cae66f2ef0ec51a9891263eeee7f987dacf0a9879e8aa9353d5606e0530619a5 AS build COPY --from=uv /uv /usr/local/bin/uv WORKDIR /app COPY pyproject.toml uv.lock README.md ./ @@ -8,7 +8,14 @@ RUN uv sync --locked --no-dev --no-install-project COPY src ./src RUN uv sync --locked --no-dev -FROM python:3.14-slim +FROM python:3.14-slim@sha256:cae66f2ef0ec51a9891263eeee7f987dacf0a9879e8aa9353d5606e0530619a5 +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \ + libssl3t64=3.5.7-1~deb13u2 \ + openssl=3.5.7-1~deb13u2 \ + openssl-provider-legacy=3.5.7-1~deb13u2 \ + && PIP_ROOT_USER_ACTION=ignore python -m pip uninstall --yes pip \ + && rm -rf /var/lib/apt/lists/* RUN useradd --system --uid 10001 --create-home app USER 10001 WORKDIR /app diff --git a/README.md b/README.md index 2422a43..5b8537a 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - HTTP API на FastAPI; - простой MVC-подобный каркас; - локальные demo-адаптеры модели и policy для разработки без внешних сервисов; +- результат с `proposal` или `clarification` для первого действия `calendar.create_event`; - Ruff, strict mypy, pytest и проверка покрытия; - русская документация MkDocs/Backstage TechDocs. @@ -47,3 +48,19 @@ uv run mkdocs build --strict Подробности: [docs/index.md](docs/index.md). Правила для разработчиков и AI-агентов: [AGENTS.md](AGENTS.md). + +## Первый сценарий + +Сервис принимает только `calendar.create_event`. Для готового предложения нужны `title`, `startAt`, +`endAt` и `timeZone`; исполнитель первой версии называется `fake-calendar`. Если полей не хватает, +ответ содержит `clarification`, а `proposal` остаётся `null`. Готовое предложение всегда содержит +`requires_approval: true`. + +Локальная demo-модель не понимает свободную речь. Для полного сквозного теста используй точный +формат: + +```text +Создай встречу "Обсуждение проекта" с 2026-09-01T12:00:00+03:00 до 2026-09-01T12:30:00+03:00 +``` + +Настоящий разбор обычной речи появится в отдельном адаптере AI-модели. diff --git a/docs/architecture.md b/docs/architecture.md index f4f3341..45f6360 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,9 +14,13 @@ sequenceDiagram Controller->>Service: propose(text, context) Service->>Model: propose(text, context) Model-->>Service: ModelReply или null - Service->>Policy: get_risk(reply, context) - Policy-->>Service: Risk - Service-->>Controller: ActionPlan + alt Не хватает обязательных полей + Service-->>Controller: Clarification + else Полный calendar.create_event + Service->>Policy: get_risk(reply, context) + Policy-->>Service: Risk + Service-->>Controller: ActionPlan + end Controller-->>Client: ProposalResponse ``` @@ -34,6 +38,21 @@ config собирает реализации; main подключает controll Зависимости направлены от HTTP к внутренней модели. `models` не импортирует FastAPI. Сервис не хранит состояние между запросами. +`ProposalService` разрешает только `calendar.create_event`, проверяет поля `title`, `startAt`, +`endAt` и `timeZone`, а затем формирует предложение с обязательным подтверждением. Проверка не +зависит от demo-модели, поэтому будущий AI-адаптер не меняет продуктовые правила. + +Внутренняя модель `CalendarEvent` запрещает лишние поля, проверяет даты, часовой пояс, размеры строк, +уникальность участников и правило `endAt > startAt`. В `ActionPlan` попадает нормализованный payload +с внешними именами полей из репозитория `contracts`. + +Ответ имеет две необязательные части: + +- `proposal` — готовые точные аргументы для передачи в `action-service`; +- `clarification` — вопрос и список полей, которые нужно получить от пользователя. + +Одновременно заполнена только одна часть. + ## Внешние контракты HTTP-путь и старые имена полей (`utterance`, `actor_id`, `available_connectors`) сохранены для diff --git a/docs/index.md b/docs/index.md index de1c6cc..0c08b3c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,7 +10,8 @@ Agent Runtime — stateless-сервис, который превращает т - приём текста и контекста; - вызов внешней AI-модели через repository-адаптер; - получение уровня риска через policy-адаптер; -- создание типизированного предложения. +- создание типизированного предложения; +- уточняющий вопрос, если для встречи не хватает обязательных данных. Сервис не отвечает за: @@ -22,3 +23,14 @@ Agent Runtime — stateless-сервис, который превращает т Текущие `DemoModelRepository` и `DemoPolicyRepository` работают только локально. Их правила — техническая заглушка, а не согласованное поведение продукта. + +## Текущий продуктовый срез + +Разрешено только действие `calendar.create_event` через `fake-calendar`. Обязательны название, +начало, конец и часовой пояс. Результат содержит либо `proposal`, либо `clarification`; обе части +могут быть `null`, если модель не нашла действие или коннектор недоступен. Создание встречи всегда +требует явного подтверждения в `action-service`. + +Перед созданием предложения сервис проверяет payload: типы и длину полей, формат времени и часового +пояса, отсутствие лишних полей и правило `endAt > startAt`. Локальная demo-модель поддерживает один +строгий формат полной команды, описанный в README; это тестовый путь, а не замена AI-модели. diff --git a/src/portable_agent/controllers/proposal_controller.py b/src/portable_agent/controllers/proposal_controller.py index 55c82b7..0668da5 100644 --- a/src/portable_agent/controllers/proposal_controller.py +++ b/src/portable_agent/controllers/proposal_controller.py @@ -14,5 +14,8 @@ async def create_proposal( request: ProposalRequest, service: Annotated[ProposalService, Depends(get_proposal_service)], ) -> ProposalResponse: - proposal = await service.propose(request.utterance, request.context.to_model()) - return ProposalResponse(proposal=proposal) + result = await service.propose(request.utterance, request.context.to_model()) + return ProposalResponse( + proposal=result.proposal, + clarification=result.clarification, + ) diff --git a/src/portable_agent/models/proposal.py b/src/portable_agent/models/proposal.py index 27bc73d..2e8efe4 100644 --- a/src/portable_agent/models/proposal.py +++ b/src/portable_agent/models/proposal.py @@ -1,8 +1,9 @@ +from datetime import datetime from enum import StrEnum -from typing import Any +from typing import Annotated, Any, Self from uuid import UUID, uuid4 -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator class Risk(StrEnum): @@ -18,6 +19,44 @@ class ModelReply(BaseModel): explanation: str = Field(min_length=1, max_length=500) +Email = Annotated[ + str, + Field(max_length=254, pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$"), +] + + +class CalendarEvent(BaseModel): + model_config = ConfigDict(extra="forbid") + + title: str = Field(min_length=1, max_length=200, pattern=r".*\S.*") + start_at: datetime = Field(alias="startAt") + end_at: datetime = Field(alias="endAt") + time_zone: str = Field( + alias="timeZone", + max_length=100, + pattern=r"^(UTC|[A-Za-z_]+(?:/[A-Za-z0-9_+-]+)+)$", + ) + description: str | None = Field(default=None, max_length=2000) + attendees: list[Email] = Field(default_factory=list, max_length=50) + + @field_validator("start_at", "end_at", mode="before") + @classmethod + def check_date_type(cls, value: object) -> object: + if not isinstance(value, str): + raise ValueError("date-time value must be a string") + return value + + @model_validator(mode="after") + def check_time(self) -> Self: + if self.start_at.tzinfo is None or self.end_at.tzinfo is None: + raise ValueError("startAt and endAt must include an offset") + if self.end_at <= self.start_at: + raise ValueError("endAt must be after startAt") + if len(self.attendees) != len(set(self.attendees)): + raise ValueError("attendees must be unique") + return self + + class ActionPlan(BaseModel): proposal_id: UUID = Field(default_factory=uuid4) kind: str @@ -28,6 +67,16 @@ class ActionPlan(BaseModel): requires_approval: bool +class Clarification(BaseModel): + question: str = Field(min_length=1, max_length=500) + missing_fields: list[str] + + +class ProposalResult(BaseModel): + proposal: ActionPlan | None = None + clarification: Clarification | None = None + + class UserContext(BaseModel): tenant_id: UUID user_id: UUID diff --git a/src/portable_agent/repositories/model_repository.py b/src/portable_agent/repositories/model_repository.py index c4d370a..5c49e94 100644 --- a/src/portable_agent/repositories/model_repository.py +++ b/src/portable_agent/repositories/model_repository.py @@ -1,3 +1,4 @@ +import re from typing import Protocol from portable_agent.models.proposal import ModelReply, UserContext @@ -15,9 +16,26 @@ async def propose(self, text: str, context: UserContext) -> ModelReply | None: if "встреч" not in normalized_text and "календар" not in normalized_text: return None + match = re.fullmatch( + # The Russian demo command intentionally uses a Cyrillic preposition. + r'Создай встречу "(?P[^"]+)" с (?P<start>\S+) до (?P<end>\S+)', # noqa: RUF001 + text, + flags=re.IGNORECASE, + ) + payload = ( + { + "title": match.group("title"), + "startAt": match.group("start"), + "endAt": match.group("end"), + "timeZone": context.timezone, + } + if match + else {"source_text": text, "timeZone": context.timezone} + ) + return ModelReply( kind="calendar.create_event", - connector="google-calendar", - payload={"source_text": text, "timezone": context.timezone}, + connector="fake-calendar", + payload=payload, explanation="Создать событие календаря по команде пользователя", ) diff --git a/src/portable_agent/schemas/proposal_schema.py b/src/portable_agent/schemas/proposal_schema.py index 1662333..9182d00 100644 --- a/src/portable_agent/schemas/proposal_schema.py +++ b/src/portable_agent/schemas/proposal_schema.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field -from portable_agent.models.proposal import ActionPlan, UserContext +from portable_agent.models.proposal import ActionPlan, Clarification, UserContext class ContextData(BaseModel): @@ -29,3 +29,4 @@ class ProposalRequest(BaseModel): class ProposalResponse(BaseModel): proposal: ActionPlan | None + clarification: Clarification | None diff --git a/src/portable_agent/services/proposal_service.py b/src/portable_agent/services/proposal_service.py index b821dc8..c07b2ec 100644 --- a/src/portable_agent/services/proposal_service.py +++ b/src/portable_agent/services/proposal_service.py @@ -1,4 +1,12 @@ -from portable_agent.models.proposal import ActionPlan, Risk, UserContext +from pydantic import ValidationError + +from portable_agent.models.proposal import ( + ActionPlan, + CalendarEvent, + Clarification, + ProposalResult, + UserContext, +) from portable_agent.repositories.model_repository import ModelRepository from portable_agent.repositories.policy_repository import PolicyRepository @@ -8,19 +16,65 @@ def __init__(self, model: ModelRepository, policy: PolicyRepository) -> None: self._model = model self._policy = policy - async def propose(self, text: str, context: UserContext) -> ActionPlan | None: + async def propose(self, text: str, context: UserContext) -> ProposalResult: reply = await self._model.propose(text, context) if reply is None: - return None + return ProposalResult() + if reply.kind != "calendar.create_event": + return ProposalResult() if reply.connector not in context.available_tools: - return None + return ProposalResult() + + missing_fields = _get_missing_fields(reply.payload) + if missing_fields: + return ProposalResult( + clarification=Clarification( + question=_get_question(missing_fields), + missing_fields=missing_fields, + ) + ) + + try: + event = CalendarEvent.model_validate(reply.payload) + except ValidationError: + return ProposalResult() risk = await self._policy.get_risk(reply, context) - return ActionPlan( - kind=reply.kind, - connector=reply.connector, - payload=reply.payload, - explanation=reply.explanation, - risk=risk, - requires_approval=risk is not Risk.LOW, + return ProposalResult( + proposal=ActionPlan( + kind=reply.kind, + connector=reply.connector, + payload=event.model_dump( + mode="json", + by_alias=True, + exclude_defaults=True, + exclude_none=True, + ), + explanation=reply.explanation, + risk=risk, + requires_approval=True, + ) ) + + +_REQUIRED_FIELDS = ("title", "startAt", "endAt", "timeZone") +_FIELD_NAMES = { + "title": "название", + "startAt": "время начала", + "endAt": "время окончания", + "timeZone": "часовой пояс", +} + + +def _get_missing_fields(payload: dict[str, object]) -> list[str]: + return [ + field + for field in _REQUIRED_FIELDS + if field not in payload or payload[field] is None or payload[field] == "" + ] + + +def _get_question(missing_fields: list[str]) -> str: + names = [_FIELD_NAMES[field] for field in missing_fields] + fields_text = names[0] if len(names) == 1 else f"{', '.join(names[:-1])} и {names[-1]}" + return f"Укажи {fields_text} встречи." diff --git a/tests/test_api.py b/tests/test_api.py index 7c5ac2d..26a9674 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -12,7 +12,7 @@ def test_live_returns_up() -> None: assert response.json() == {"status": "UP"} -def test_create_proposal_for_calendar_command_returns_approval_proposal() -> None: +def test_create_proposal_for_incomplete_calendar_command_returns_clarification() -> None: response = client.post( "/api/v1/proposals", json={ @@ -20,12 +20,44 @@ def test_create_proposal_for_calendar_command_returns_approval_proposal() -> Non "context": { "tenant_id": "8efb312d-5e66-4314-a4ef-d7931932b35a", "actor_id": "29e924b6-2c85-4fa1-88ca-dffbde14633b", - "available_connectors": ["google-calendar"], + "available_connectors": ["fake-calendar"], }, }, ) assert response.status_code == 200 - body = response.json()["proposal"] - assert body["kind"] == "calendar.create_event" - assert body["requires_approval"] is True + body = response.json() + assert body["proposal"] is None + assert body["clarification"] == { + "question": "Укажи название, время начала и время окончания встречи.", + "missing_fields": ["title", "startAt", "endAt"], + } + + +def test_create_proposal_for_full_demo_command_returns_approval_proposal() -> None: + response = client.post( + "/api/v1/proposals", + json={ + "utterance": ( + 'Создай встречу "Обсуждение проекта" ' + "с 2026-09-01T12:00:00+03:00 до 2026-09-01T12:30:00+03:00" + ), + "context": { + "tenant_id": "8efb312d-5e66-4314-a4ef-d7931932b35a", + "actor_id": "29e924b6-2c85-4fa1-88ca-dffbde14633b", + "timezone": "Europe/Moscow", + "available_connectors": ["fake-calendar"], + }, + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["clarification"] is None + assert body["proposal"]["payload"] == { + "title": "Обсуждение проекта", + "startAt": "2026-09-01T12:00:00+03:00", + "endAt": "2026-09-01T12:30:00+03:00", + "timeZone": "Europe/Moscow", + } + assert body["proposal"]["requires_approval"] is True diff --git a/tests/test_proposal_service.py b/tests/test_proposal_service.py index 0484f3a..6840e18 100644 --- a/tests/test_proposal_service.py +++ b/tests/test_proposal_service.py @@ -29,31 +29,83 @@ def context(*tools: str) -> UserContext: @pytest.mark.asyncio -async def test_propose_when_connector_available_returns_typed_proposal() -> None: +async def test_propose_full_calendar_event_returns_approval_proposal() -> None: call = ModelReply( kind="calendar.create_event", - connector="google-calendar", - payload={"title": "Demo"}, + connector="fake-calendar", + payload={ + "title": "Обсуждение проекта", + "startAt": "2026-09-01T12:00:00+03:00", + "endAt": "2026-09-01T12:30:00+03:00", + "timeZone": "Europe/Moscow", + }, + explanation="Create event", + ) + service = ProposalService(FakeModel(call), FakePolicy(Risk.LOW)) + + result = await service.propose("Создай встречу", context("fake-calendar")) + + assert result.proposal is not None + assert result.proposal.kind == "calendar.create_event" + assert result.proposal.payload == call.payload + assert result.proposal.risk is Risk.LOW + assert result.proposal.requires_approval is True + assert result.clarification is None + + +@pytest.mark.asyncio +async def test_propose_incomplete_calendar_event_returns_clarification() -> None: + call = ModelReply( + kind="calendar.create_event", + connector="fake-calendar", + payload={"title": "Обсуждение проекта"}, explanation="Create event", ) service = ProposalService(FakeModel(call), FakePolicy(Risk.MEDIUM)) - proposal = await service.propose("Создай встречу", context("google-calendar")) + result = await service.propose("Создай встречу", context("fake-calendar")) - assert proposal is not None - assert proposal.kind == "calendar.create_event" - assert proposal.requires_approval is True + assert result.proposal is None + assert result.clarification is not None + assert result.clarification.missing_fields == ["startAt", "endAt", "timeZone"] + assert result.clarification.question == ( + "Укажи время начала, время окончания и часовой пояс встречи." + ) @pytest.mark.asyncio async def test_propose_when_model_returns_no_call_returns_none() -> None: service = ProposalService(FakeModel(None), FakePolicy(Risk.LOW)) - assert await service.propose("Привет", context("google-calendar")) is None + result = await service.propose("Привет", context("fake-calendar")) + + assert result.proposal is None + assert result.clarification is None @pytest.mark.asyncio async def test_propose_when_connector_unavailable_rejects_proposal() -> None: + call = ModelReply( + kind="calendar.create_event", + connector="fake-calendar", + payload={ + "title": "Demo", + "startAt": "2026-09-01T12:00:00+03:00", + "endAt": "2026-09-01T12:30:00+03:00", + "timeZone": "Europe/Moscow", + }, + explanation="Create event", + ) + service = ProposalService(FakeModel(call), FakePolicy(Risk.HIGH)) + + result = await service.propose("Создай встречу", context("google-calendar")) + + assert result.proposal is None + assert result.clarification is None + + +@pytest.mark.asyncio +async def test_propose_when_action_is_not_allowed_returns_empty_result() -> None: call = ModelReply( kind="wallet.transfer", connector="wallet", @@ -62,20 +114,81 @@ async def test_propose_when_connector_unavailable_rejects_proposal() -> None: ) service = ProposalService(FakeModel(call), FakePolicy(Risk.HIGH)) - assert await service.propose("Переведи деньги", context("google-calendar")) is None + result = await service.propose("Переведи деньги", context("wallet")) + + assert result.proposal is None + assert result.clarification is None @pytest.mark.asyncio -async def test_propose_when_risk_is_low_does_not_require_approval() -> None: +async def test_propose_when_only_title_is_missing_asks_for_title() -> None: call = ModelReply( - kind="calendar.read", - connector="google-calendar", - payload={}, - explanation="Read calendar", + kind="calendar.create_event", + connector="fake-calendar", + payload={ + "startAt": "2026-09-01T12:00:00+03:00", + "endAt": "2026-09-01T12:30:00+03:00", + "timeZone": "Europe/Moscow", + }, + explanation="Create event", ) - service = ProposalService(FakeModel(call), FakePolicy(Risk.LOW)) + service = ProposalService(FakeModel(call), FakePolicy(Risk.MEDIUM)) + + result = await service.propose("Создай встречу", context("fake-calendar")) + + assert result.clarification is not None + assert result.clarification.question == "Укажи название встречи." + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload", + [ + { + "title": " ", + "startAt": "2026-09-01T12:00:00+03:00", + "endAt": "2026-09-01T12:30:00+03:00", + "timeZone": "Europe/Moscow", + }, + { + "title": "Demo", + "startAt": "not-a-date", + "endAt": "2026-09-01T12:30:00+03:00", + "timeZone": "Europe/Moscow", + }, + { + "title": "Demo", + "startAt": 0, + "endAt": 1800, + "timeZone": "Europe/Moscow", + }, + { + "title": "Demo", + "startAt": "2026-09-01T13:00:00+03:00", + "endAt": "2026-09-01T12:30:00+03:00", + "timeZone": "Europe/Moscow", + }, + { + "title": "Demo", + "startAt": "2026-09-01T12:00:00+03:00", + "endAt": "2026-09-01T12:30:00+03:00", + "timeZone": "Europe/Moscow", + "hidden": "value", + }, + ], +) +async def test_propose_when_calendar_payload_is_invalid_returns_empty_result( + payload: dict[str, object], +) -> None: + call = ModelReply( + kind="calendar.create_event", + connector="fake-calendar", + payload=payload, + explanation="Create event", + ) + service = ProposalService(FakeModel(call), FakePolicy(Risk.MEDIUM)) - proposal = await service.propose("Что в календаре", context("google-calendar")) + result = await service.propose("Создай встречу", context("fake-calendar")) - assert proposal is not None - assert proposal.requires_approval is False + assert result.proposal is None + assert result.clarification is None