Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
# 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 ./
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
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- HTTP API на FastAPI;
- простой MVC-подобный каркас;
- локальные demo-адаптеры модели и policy для разработки без внешних сервисов;
- результат с `proposal` или `clarification` для первого действия `calendar.create_event`;
- Ruff, strict mypy, pytest и проверка покрытия;
- русская документация MkDocs/Backstage TechDocs.

Expand Down Expand Up @@ -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-модели.
25 changes: 22 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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`) сохранены для
Expand Down
14 changes: 13 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ Agent Runtime — stateless-сервис, который превращает т
- приём текста и контекста;
- вызов внешней AI-модели через repository-адаптер;
- получение уровня риска через policy-адаптер;
- создание типизированного предложения.
- создание типизированного предложения;
- уточняющий вопрос, если для встречи не хватает обязательных данных.

Сервис не отвечает за:

Expand All @@ -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-модели.
7 changes: 5 additions & 2 deletions src/portable_agent/controllers/proposal_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
53 changes: 51 additions & 2 deletions src/portable_agent/models/proposal.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Expand All @@ -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
Expand Down
22 changes: 20 additions & 2 deletions src/portable_agent/repositories/model_repository.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from typing import Protocol

from portable_agent.models.proposal import ModelReply, UserContext
Expand All @@ -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<title>[^"]+)" с (?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="Создать событие календаря по команде пользователя",
)
3 changes: 2 additions & 1 deletion src/portable_agent/schemas/proposal_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -29,3 +29,4 @@ class ProposalRequest(BaseModel):

class ProposalResponse(BaseModel):
proposal: ActionPlan | None
clarification: Clarification | None
76 changes: 65 additions & 11 deletions src/portable_agent/services/proposal_service.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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} встречи."
42 changes: 37 additions & 5 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,52 @@ 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={
"utterance": "Создай встречу с Колей",
"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
Loading