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\S+) до (?P\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