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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

- HTTP API на FastAPI;
- Bearer JWT с проверкой подписи, issuer, audience и срока жизни;
- контракт `portable-agent/contracts` версии `2.1.0`;
- контракт `portable-agent/contracts` версии `3.0.0`;
- простой MVC-подобный каркас;
- внешний порт `IntentModel` и локальная `DemoIntentModel` для разработки без AI-сервиса;
- результат с `proposal` или `clarification` для первого действия `calendar.create_event`;
Expand Down Expand Up @@ -82,8 +82,9 @@ uv run mkdocs build --strict
## Первый сценарий

Сервис принимает только `calendar.create_event`. Для готового предложения нужны `title`, `startAt`,
`endAt` и `timeZone`; исполнитель первой версии называется `fake-calendar`. Если полей не хватает,
ответ содержит `clarification`, а `proposal` остаётся `null`. Готовое предложение всегда содержит
`endAt` и `timeZone`. Доступны `fake-calendar` и `google-calendar`; demo-модель выбирает Google, если
клиент передал его в `availableConnectors`, иначе использует fake. Если полей не хватает, ответ
содержит `clarification`, а `proposal` остаётся `null`. Готовое предложение всегда содержит
`requiresApproval: true`.

Внешний запрос использует поля `text`, `timeZone` и `availableConnectors`. Проверенная копия схемы
Expand Down
6 changes: 3 additions & 3 deletions contracts/agent-runtime-api.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Portable Agent Runtime API
version: 2.1.0
version: 3.0.0
description: Подготовка предложения действия из текста пользователя.
license:
name: Apache-2.0
Expand Down Expand Up @@ -77,7 +77,7 @@ components:
uniqueItems: true
items:
type: string
enum: [fake-calendar]
enum: [fake-calendar, google-calendar]
default: []
ProposalResponse:
type: object
Expand Down Expand Up @@ -112,7 +112,7 @@ components:
enum: [calendar.create_event]
connector:
type: string
enum: [fake-calendar]
enum: [fake-calendar, google-calendar]
payload:
$ref: "#/components/schemas/CalendarCreateEventPayload"
explanation:
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,6 @@ config собирает реализации; main подключает controll

## Внешний контракт

HTTP API следует `portable-agent/contracts` версии `2.1.0`: `text`, `timeZone`,
HTTP API следует `portable-agent/contracts` версии `3.0.0`: `text`, `timeZone`,
`availableConnectors`, `proposalId`, `requiresApproval` и `missingFields`. Копия релизной схемы
лежит в `contracts/`; contract-тест проверяет по ней настоящий запрос и ответ.
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ uv run mkdocs build --strict
При обновлении общего API сначала выпусти релиз в `portable-agent/contracts`, затем выполни:

```powershell
.\scripts\update-contract.ps1 -Version 2.1.0
.\scripts\update-contract.ps1 -Version 3.0.0
```

Ручное копирование схемы запрещено: команда проверяет checksum и GitHub attestation.
10 changes: 5 additions & 5 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ Completions API и проверяет JSON-ответ через внутрен

## Текущий продуктовый срез

Разрешено только действие `calendar.create_event` через `fake-calendar`. Обязательны название,
начало, конец и часовой пояс. Результат содержит либо `proposal`, либо `clarification`; обе части
могут быть `null`, если модель не нашла действие или коннектор недоступен. Создание встречи всегда
требует явного подтверждения в `action-service`.
Разрешено только действие `calendar.create_event` через `fake-calendar` или `google-calendar`.
Обязательны название, начало, конец и часовой пояс. Результат содержит либо `proposal`, либо
`clarification`; обе части могут быть `null`, если модель не нашла действие или коннектор недоступен.
Создание встречи всегда требует явного подтверждения в `action-service`.

Публичный API соответствует `portable-agent/contracts` версии `2.1.0`. Поля называются `text`,
Публичный API соответствует `portable-agent/contracts` версии `3.0.0`. Поля называются `text`,
`timeZone`, `availableConnectors`, `proposalId`, `requiresApproval` и `missingFields`.

Перед созданием предложения сервис проверяет payload: типы и длину полей, формат времени и часового
Expand Down
5 changes: 4 additions & 1 deletion src/portable_agent/models/proposal.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ class ModelReply(BaseModel):
explanation: str = Field(min_length=1, max_length=500)


CalendarConnector = Literal["fake-calendar", "google-calendar"]


Email = Annotated[
str,
Field(max_length=254, pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$"),
Expand Down Expand Up @@ -67,7 +70,7 @@ class ActionPlan(BaseModel):

proposal_id: UUID = Field(default_factory=uuid4, alias="proposalId")
kind: Literal["calendar.create_event"]
connector: Literal["fake-calendar"]
connector: CalendarConnector
payload: dict[str, Any]
explanation: str
risk: Risk
Expand Down
13 changes: 12 additions & 1 deletion src/portable_agent/repositories/model_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ async def propose(self, text: str, context: UserContext) -> ModelReply | None:
if "встреч" not in normalized_text and "календар" not in normalized_text:
return None

connector = next(
(
name
for name in ("google-calendar", "fake-calendar")
if name in context.available_tools
),
None,
)
if connector is None:
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
Expand All @@ -37,7 +48,7 @@ async def propose(self, text: str, context: UserContext) -> ModelReply | None:

return ModelReply(
kind="calendar.create_event",
connector="fake-calendar",
connector=connector,
payload=payload,
explanation="Создать событие календаря по команде пользователя",
)
6 changes: 2 additions & 4 deletions src/portable_agent/schemas/proposal_schema.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field

from portable_agent.models.proposal import ActionPlan, Clarification, UserContext
from portable_agent.models.proposal import ActionPlan, CalendarConnector, Clarification, UserContext
from portable_agent.models.user import TokenUser


Expand All @@ -17,7 +15,7 @@ class ContextData(BaseModel):
max_length=100,
pattern=r"^(UTC|[A-Za-z_]+(?:/[A-Za-z0-9_+-]+)+)$",
)
available_connectors: set[Literal["fake-calendar"]] = Field(
available_connectors: set[CalendarConnector] = Field(
default_factory=set,
alias="availableConnectors",
)
Expand Down
12 changes: 12 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,18 @@ def test_create_proposal_for_full_command_should_return_contract_response() -> N
assert "proposalId" in body["proposal"]


def test_create_proposal_when_google_calendar_is_available_should_choose_it() -> None:
request = valid_request()
context = request["context"]
assert isinstance(context, dict)
context["availableConnectors"] = ["fake-calendar", "google-calendar"]

response = client.post("/api/v1/proposals", headers=auth, json=request)

assert response.status_code == 200
assert response.json()["proposal"]["connector"] == "google-calendar"


def valid_request() -> dict[str, object]:
return {
"text": (
Expand Down
4 changes: 2 additions & 2 deletions tests/test_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ def test_api_when_request_and_response_are_valid_should_match_contract() -> None
validate(response.json(), "ProposalResponse", contract)


def test_contract_version_should_be_2_1_0() -> None:
assert read_contract()["info"]["version"] == "2.1.0"
def test_contract_version_should_be_3_0_0() -> None:
assert read_contract()["info"]["version"] == "3.0.0"


def read_contract() -> dict[str, Any]:
Expand Down
31 changes: 29 additions & 2 deletions tests/test_demo_intent_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
from portable_agent.repositories.model_repository import DemoIntentModel


def context() -> UserContext:
def context(*connectors: str) -> UserContext:
return UserContext(
tenant_id=uuid4(),
user_id=uuid4(),
timezone="Europe/Moscow",
available_tools={"fake-calendar"},
available_tools=set(connectors or ("fake-calendar",)),
)


Expand All @@ -36,10 +36,37 @@ async def test_demo_model_when_command_is_complete_should_return_calendar_reply(
}


@pytest.mark.asyncio
async def test_demo_model_when_google_calendar_is_available_should_choose_it() -> None:
model = DemoIntentModel()

reply = await model.propose(
'Создай встречу "Обсуждение проекта" с 2026-09-01T12:00:00+03:00 '
"до 2026-09-01T12:30:00+03:00",
context("fake-calendar", "google-calendar"),
)

assert reply is not None
assert reply.connector == "google-calendar"


@pytest.mark.asyncio
async def test_demo_model_when_text_is_not_calendar_command_should_return_none() -> None:
model = DemoIntentModel()

reply = await model.propose("Привет", context())

assert reply is None


@pytest.mark.asyncio
async def test_demo_model_when_calendar_connector_is_unavailable_should_return_none() -> None:
model = DemoIntentModel()

reply = await model.propose(
'Создай встречу "Обсуждение проекта" с 2026-09-01T12:00:00+03:00 '
"до 2026-09-01T12:30:00+03:00",
context("wallet"),
)

assert reply is None
Loading