diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
new file mode 100644
index 0000000..ebe5576
--- /dev/null
+++ b/.github/workflows/python-app.yml
@@ -0,0 +1,30 @@
+name: Python CI
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ['3.12']
+
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install Poetry
+ run: pipx install poetry
+ - name: Install dependencies
+ run: poetry install --all-extras
+ - name: Run pre-commit hooks
+ run: poetry run pre-commit run --all-files --show-diff-on-failure
+ - name: Run tests
+ run: poetry run pytest
diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml
new file mode 100644
index 0000000..58d6a3a
--- /dev/null
+++ b/.github/workflows/release-please.yml
@@ -0,0 +1,19 @@
+name: release-please
+
+on:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ release-please:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: googleapis/release-please-action@v4
+ with:
+ config-file: release-please-config.json
+ manifest-file: .release-please-manifest.json
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..25402ee
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,22 @@
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v6.0.0
+ hooks:
+ - id: end-of-file-fixer
+ - id: trailing-whitespace
+ - id: check-yaml
+ - id: check-toml
+ - id: check-added-large-files
+
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.15.11
+ hooks:
+ - id: ruff
+ args: [--fix]
+ - id: ruff-format
+
+ - repo: https://github.com/commitizen-tools/commitizen
+ rev: v4.4.1
+ hooks:
+ - id: commitizen
+ stages: [commit-msg]
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
new file mode 100644
index 0000000..e18ee07
--- /dev/null
+++ b/.release-please-manifest.json
@@ -0,0 +1,3 @@
+{
+ ".": "0.0.0"
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..413b5d6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,95 @@
+# DialogEngine
+
+[](https://github.com/k0te1ch/DialogEngine/actions/workflows/python-app.yml)
+[](https://www.python.org/downloads/)
+[](LICENSE)
+
+`DialogEngine` — лёгкий движок многошаговых диалогов на чистом Python (stdlib, без
+обязательных зависимостей). Помогает строить сложные form-driven сценарии и
+Telegram-ботов без спагетти из хендлеров: схема диалога описывается данными, а
+движок управляет навигацией, валидацией и состоянием сессии.
+
+## Возможности
+
+- Описание диалога данными (`dict` / JSON / список шагов) — без хардкода переходов.
+- Условные переходы между шагами (`next` по значению ответа).
+- Встроенная валидация ответов + кастомные валидаторы, синхронные и асинхронные.
+- Резолверы текста с подстановкой из накопленных ответов (`{name}` и т.п.).
+- Управление сессией: статусы `IN_PROGRESS / COMPLETED / CANCELLED`.
+- Опциональные extras: `pydantic`-валидация, загрузка схем из YAML, интеграция с `aiogram`.
+
+## Установка
+
+```bash
+pip install dialog-engine
+# с дополнительными возможностями:
+pip install "dialog-engine[validation,yaml,aiogram]"
+```
+
+Для разработки используется [Poetry](https://python-poetry.org/):
+
+```bash
+poetry install --all-extras
+```
+
+## Быстрый старт
+
+```python
+from dialog_engine import DialogEngine
+
+engine = DialogEngine.from_list([
+ {"id": "name", "type": "text", "text": "Как вас зовут?"},
+ {"id": "age", "type": "number", "text": "Сколько вам лет?", "min": 1},
+])
+
+session = engine.create_session()
+
+while (step := engine.current_step(session)) is not None:
+ answer = input(engine.resolve_text(step, session) + " ")
+ try:
+ engine.submit(session, answer)
+ except ValueError as exc:
+ print("Ошибка:", exc)
+
+print("Готово!", session.answers)
+```
+
+## Асинхронный режим
+
+Движок поддерживает асинхронные валидаторы и резолверы текста через
+`async_submit` / `async_resolve_text`. Полный пример —
+[examples/async_validators_example.py](examples/async_validators_example.py).
+
+## Основной API
+
+- `DialogEngine` — загрузка схемы и управление потоком диалога.
+- `DialogSession` / `SessionStatus` — состояние одного запуска диалога.
+- `DialogStep` / `StepType` — описание шага и его тип.
+- `validate` — встроенная валидация ответов.
+- `DialogError`, `ValidationError`, `StepNotFoundError` — иерархия исключений.
+
+## Разработка
+
+```bash
+poetry install --all-extras
+poetry run pre-commit install --hook-type pre-commit --hook-type commit-msg
+poetry run pytest
+poetry run pre-commit run --all-files
+```
+
+Коммиты — по [Conventional Commits](https://www.conventionalcommits.org/);
+формат проверяет хук `commitizen`. Релизы, теги и `CHANGELOG.md` полностью
+автоматизированы через [release-please](https://github.com/googleapis/release-please).
+Полный гайд с диаграммами — [docs/WORKFLOW.md](docs/WORKFLOW.md).
+
+## Структура проекта
+
+- `dialog_engine/` — исходный код библиотеки.
+- `tests/` — набор тестов (`pytest`).
+- `examples/` — примеры использования.
+- `docs/WORKFLOW.md` — гайд по веткам, коммитам и релизам.
+- `.github/workflows/` — CI и release-please.
+
+## Лицензия
+
+[MIT](LICENSE)
diff --git a/dialog_engine/__init__.py b/dialog_engine/__init__.py
new file mode 100644
index 0000000..456e6f3
--- /dev/null
+++ b/dialog_engine/__init__.py
@@ -0,0 +1,61 @@
+"""dialog_engine — universal multi-step dialog engine.
+
+Quick start::
+
+ from dialog_engine import DialogEngine
+
+ engine = DialogEngine.from_file("dialogs/onboarding.json")
+ session = engine.create_session()
+
+ while (step := engine.current_step(session)) is not None:
+ answer = input(engine.resolve_text(step, session) + " ")
+ try:
+ engine.submit(session, answer)
+ except ValidationError as exc:
+ print("Error:", exc)
+
+Public API
+----------
+Classes:
+ DialogEngine — loads a dialog schema, drives navigation
+ DialogSession — mutable run-time state for one dialog instance
+ DialogStep — a single step in the dialog schema
+ SessionStatus — enum: IN_PROGRESS / COMPLETED / CANCELLED
+
+Exceptions:
+ DialogError — base exception
+ StepNotFoundError — unknown step ID referenced
+ ValidationError — submitted answer fails validation
+
+Types:
+ StepType — Literal union of all supported step type strings
+ TextResolver — Callable used by the engine to resolve display text
+"""
+
+from .engine import DialogEngine, TextResolver
+from .exceptions import DialogError, StepNotFoundError, ValidationError
+from .session import DialogSession, SessionStatus
+from .step import DialogStep, StepType
+from .validators import validate
+
+__version__ = "0.0.0" # x-release-please-version
+
+__all__ = [
+ # Engine
+ "DialogEngine",
+ "TextResolver",
+ # Session
+ "DialogSession",
+ "SessionStatus",
+ # Step
+ "DialogStep",
+ "StepType",
+ # Validators
+ "validate",
+ # Exceptions
+ "DialogError",
+ "StepNotFoundError",
+ "ValidationError",
+ # Metadata
+ "__version__",
+]
diff --git a/dialog_engine/engine.py b/dialog_engine/engine.py
new file mode 100644
index 0000000..59610d7
--- /dev/null
+++ b/dialog_engine/engine.py
@@ -0,0 +1,444 @@
+"""DialogEngine — orchestrates a dialog flow."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from collections.abc import Awaitable, Callable
+from pathlib import Path
+from typing import Any
+
+from .exceptions import DialogError, StepNotFoundError
+from .session import DialogSession, SessionStatus
+from .step import DialogStep
+from .validators import async_validate as _async_validate
+from .validators import sync_validate as _validate
+
+# ── Text resolver type ────────────────────────────────────────────────────────
+
+TextResolver = Callable[[str, dict[str, Any]], str]
+AsyncTextResolver = Callable[[str, dict[str, Any]], Awaitable[str]]
+"""A callable that resolves a step's ``text`` field for display.
+
+Signature::
+
+ def resolver(key: str, context: dict[str, Any]) -> str: ...
+
+*key* is ``step.text``; *context* carries the session answers so
+the resolver can interpolate dynamic values.
+
+The default resolver returns *key* unchanged, which works well when
+``text`` already contains the full display string.
+"""
+
+
+def _passthrough_resolver(key: str, _ctx: dict[str, Any]) -> str:
+ return key
+
+
+# ── Engine ────────────────────────────────────────────────────────────────────
+
+
+class DialogEngine:
+ """Universal multi-step dialog engine.
+
+ The engine owns the *schema* (steps + branching logic) and is
+ stateless with respect to individual runs. All mutable state lives
+ in :class:`~dialog_engine.DialogSession` objects.
+
+ Typical usage::
+
+ engine = DialogEngine.from_file("dialogs/onboarding.json")
+
+ session = engine.create_session()
+ step = engine.current_step(session) # → first step
+ print(engine.resolve_text(step, session)) # display the question
+
+ next_step = engine.submit(session, "Alice")
+ # … repeat until next_step is None (dialog complete)
+
+ JSON format
+ -----------
+ Bare list::
+
+ [{"id": "name", "type": "text", "text": "Your name?"}, …]
+
+ Wrapped dict (recommended — carries ``id`` and optional metadata)::
+
+ {
+ "id": "onboarding",
+ "steps": [{"id": "name", "type": "text", "text": "Your name?"}, …]
+ }
+
+ Branching example::
+
+ {
+ "id": "country",
+ "type": "choice",
+ "text": "Choose country",
+ "choices": {"RU": "Russia", "OTHER": "Other"},
+ "next": {"RU": "passport_ru", "OTHER": "passport_other"}
+ }
+
+ Use ``"_end"`` as a branch target to terminate the dialog early.
+ """
+
+ def __init__(
+ self,
+ steps: list[DialogStep],
+ dialog_id: str = "dialog",
+ text_resolver: TextResolver | AsyncTextResolver | None = None,
+ ) -> None:
+ if not steps:
+ raise DialogError("A dialog must have at least one step.")
+ self.dialog_id = dialog_id
+ self.steps = list(steps)
+ self._by_id: dict[str, int] = {s.id: i for i, s in enumerate(steps)}
+ self.text_resolver: TextResolver | AsyncTextResolver = (
+ text_resolver or _passthrough_resolver
+ )
+ self._is_async_resolver = asyncio.iscoroutinefunction(text_resolver)
+
+ # ── Constructors ──────────────────────────────────────────────────────────
+
+ @classmethod
+ def from_file(
+ cls,
+ path: str | Path,
+ text_resolver: TextResolver | None = None,
+ ) -> DialogEngine:
+ """Load a dialog from a JSON file.
+
+ Supports both bare-list and wrapped-dict formats (see class docstring).
+ """
+ path = Path(path)
+ raw = json.loads(path.read_text(encoding="utf-8"))
+ if isinstance(raw, dict):
+ dialog_id: str = raw.get("id", path.stem)
+ steps_data: list[dict] = raw["steps"]
+ else:
+ dialog_id = path.stem
+ steps_data = raw
+ return cls(
+ [DialogStep.from_dict(s) for s in steps_data],
+ dialog_id=dialog_id,
+ text_resolver=text_resolver,
+ )
+
+ @classmethod
+ def from_list(
+ cls,
+ data: list[dict],
+ dialog_id: str = "dialog",
+ text_resolver: TextResolver | None = None,
+ ) -> DialogEngine:
+ """Create a dialog from a list of step dicts."""
+ return cls(
+ [DialogStep.from_dict(s) for s in data],
+ dialog_id=dialog_id,
+ text_resolver=text_resolver,
+ )
+
+ # ── Session management ────────────────────────────────────────────────────
+
+ def create_session(self, start_index: int = 0) -> DialogSession:
+ """Create a fresh session starting at *start_index*."""
+ if not (0 <= start_index < len(self.steps)):
+ raise DialogError(
+ f"start_index {start_index} is out of range (0–{len(self.steps) - 1})."
+ )
+ session = DialogSession(dialog_id=self.dialog_id)
+ session._history = [start_index]
+ return session
+
+ def restore_session(self, data: dict[str, Any]) -> DialogSession:
+ """Restore a session from a previously serialised dict."""
+ return DialogSession.from_dict(data)
+
+ # ── Navigation ────────────────────────────────────────────────────────────
+
+ def current_step(self, session: DialogSession) -> DialogStep | None:
+ """Return the step the user is currently on, or ``None`` if finished."""
+ if not session.is_active:
+ return None
+ return self.steps[session.current_index]
+
+ def submit(self, session: DialogSession, value: Any) -> DialogStep | None:
+ """Submit an answer for the current step.
+
+ Validates *value*, stores it in the session, and advances to the
+ next step.
+
+ Returns:
+ The next :class:`~dialog_engine.DialogStep`, or ``None`` when
+ the dialog is complete.
+
+ Raises:
+ :class:`~dialog_engine.ValidationError` if *value* is invalid.
+ :class:`~dialog_engine.DialogError` if the session is not active.
+ """
+ self._assert_active(session)
+ step = self._require_current(session)
+
+ cleaned = _validate(step, value)
+ session.answers[step.id] = cleaned
+
+ next_idx = self._resolve_next_index(step, cleaned, session.current_index)
+ if next_idx is None:
+ session.status = SessionStatus.COMPLETED
+ return None
+
+ session._history.append(next_idx)
+ return self.steps[next_idx]
+
+ async def async_submit(
+ self, session: DialogSession, value: Any
+ ) -> DialogStep | None:
+ """Submit an answer for the current step asynchronously.
+
+ Validates *value*, stores it in the session, and advances to the
+ next step.
+
+ Returns:
+ The next :class:`~dialog_engine.DialogStep`, or ``None`` when
+ the dialog is complete.
+
+ Raises:
+ :class:`~dialog_engine.ValidationError` if *value* is invalid.
+ :class:`~dialog_engine.DialogError` if the session is not active.
+ """
+ self._assert_active(session)
+ step = self._require_current(session)
+
+ cleaned = await _async_validate(step, value)
+ session.answers[step.id] = cleaned
+
+ next_idx = self._resolve_next_index(step, cleaned, session.current_index)
+ if next_idx is None:
+ session.status = SessionStatus.COMPLETED
+ return None
+
+ session._history.append(next_idx)
+ return self.steps[next_idx]
+
+ def skip(self, session: DialogSession) -> DialogStep | None:
+ """Skip the current *optional* step (``required=False``).
+
+ Returns:
+ The next step, or ``None`` if the dialog is complete.
+
+ Raises:
+ :class:`~dialog_engine.DialogError` if the step is required.
+ """
+ self._assert_active(session)
+ step = self._require_current(session)
+ if step.required:
+ raise DialogError(f"Step {step.id!r} is required and cannot be skipped.")
+
+ session.answers[step.id] = None
+ next_idx = self._resolve_next_index(step, None, session.current_index)
+ if next_idx is None:
+ session.status = SessionStatus.COMPLETED
+ return None
+
+ session._history.append(next_idx)
+ return self.steps[next_idx]
+
+ async def async_skip(self, session: DialogSession) -> DialogStep | None:
+ """Skip the current *optional* step (``required=False``) asynchronously.
+
+ Returns:
+ The next step, or ``None`` if the dialog is complete.
+
+ Raises:
+ :class:`~dialog_engine.DialogError` if the step is required.
+ """
+ self._assert_active(session)
+ step = self._require_current(session)
+ if step.required:
+ raise DialogError(f"Step {step.id!r} is required and cannot be skipped.")
+
+ session.answers[step.id] = None
+ next_idx = self._resolve_next_index(step, None, session.current_index)
+ if next_idx is None:
+ session.status = SessionStatus.COMPLETED
+ return None
+
+ session._history.append(next_idx)
+ return self.steps[next_idx]
+
+ def back(self, session: DialogSession) -> DialogStep:
+ """Go back to the previous step and clear its stored answer.
+
+ Raises:
+ :class:`~dialog_engine.DialogError` if already on the first step.
+ """
+ self._assert_active(session)
+ if len(session._history) <= 1:
+ raise DialogError("Already on the first step.")
+
+ # Drop the current (unanswered) step.
+ session._history.pop()
+ # The step we're returning to will be re-answered, so clear its value.
+ prev_step = self.steps[session._history[-1]]
+ session.answers.pop(prev_step.id, None)
+ return prev_step
+
+ async def async_back(self, session: DialogSession) -> DialogStep:
+ """Go back to the previous step and clear its stored answer asynchronously.
+
+ Raises:
+ :class:`~dialog_engine.DialogError` if already on the first step.
+ """
+ self._assert_active(session)
+ if len(session._history) <= 1:
+ raise DialogError("Already on the first step.")
+
+ # Drop the current (unanswered) step.
+ session._history.pop()
+ # The step we're returning to will be re-answered, so clear its value.
+ prev_step = self.steps[session._history[-1]]
+ session.answers.pop(prev_step.id, None)
+ return prev_step
+
+ def jump_to(self, session: DialogSession, step_id: str) -> DialogStep:
+ """Jump directly to the step with the given *step_id*.
+
+ Useful for edit flows where the user wants to revisit a specific step.
+ The step's previous answer is cleared so it can be re-submitted.
+ """
+ self._assert_active(session)
+ idx = self._by_id.get(step_id)
+ if idx is None:
+ raise StepNotFoundError(step_id)
+ session._history.append(idx)
+ session.answers.pop(step_id, None)
+ return self.steps[idx]
+
+ def cancel(self, session: DialogSession) -> None:
+ """Mark the session as cancelled."""
+ session.status = SessionStatus.CANCELLED
+
+ # ── Queries ───────────────────────────────────────────────────────────────
+
+ def resolve_text(
+ self,
+ step: DialogStep,
+ session: DialogSession | None = None,
+ ) -> str:
+ """Return the display text for *step* via the :attr:`text_resolver`.
+
+ Passes session answers as context so the resolver can interpolate
+ dynamic values (e.g. ``"Hello {name}!"``).
+ """
+ ctx: dict[str, Any] = session.answers if session is not None else {}
+ if self._is_async_resolver:
+ raise DialogError(
+ "Async text resolver requires calling async_resolve_text() instead"
+ )
+ return self.text_resolver(step.text, ctx)
+
+ async def async_resolve_text(
+ self,
+ step: DialogStep,
+ session: DialogSession | None = None,
+ ) -> str:
+ """Return the display text for *step* via the async :attr:`text_resolver`.
+
+ Passes session answers as context so the resolver can interpolate
+ dynamic values (e.g. ``"Hello {name}!"``).
+ """
+ ctx: dict[str, Any] = session.answers if session is not None else {}
+ if not self._is_async_resolver:
+ return self.resolve_text(step, session)
+ return await self.text_resolver(step.text, ctx)
+
+ def get_step(self, index: int) -> DialogStep | None:
+ """Return the step at *index*, or ``None`` if out of range."""
+ return self.steps[index] if 0 <= index < len(self.steps) else None
+
+ def get_step_by_id(self, step_id: str) -> DialogStep:
+ """Return the step with the given ID.
+
+ Raises :class:`~dialog_engine.StepNotFoundError` if not found.
+ """
+ idx = self._by_id.get(step_id)
+ if idx is None:
+ raise StepNotFoundError(step_id)
+ return self.steps[idx]
+
+ def progress(self, session: DialogSession) -> tuple[int, int]:
+ """Return ``(step_number, total)`` for display (e.g. "Step 2 of 5").
+
+ *step_number* is 1-based.
+ """
+ return len(session._history), len(self.steps)
+
+ def is_last(self, index: int) -> bool:
+ """``True`` if *index* refers to the last step."""
+ return index >= len(self.steps) - 1
+
+ def total(self) -> int:
+ """Total number of steps in this dialog."""
+ return len(self.steps)
+
+ def to_dict(self) -> dict[str, Any]:
+ """Serialise the dialog schema to a plain dict."""
+ return {
+ "id": self.dialog_id,
+ "steps": [s.to_dict() for s in self.steps],
+ }
+
+ # ── Internal helpers ──────────────────────────────────────────────────────
+
+ def _assert_active(self, session: DialogSession) -> None:
+ if not session.is_active:
+ raise DialogError(f"Session is {session.status.value}, not in-progress.")
+
+ def _require_current(self, session: DialogSession) -> DialogStep:
+ step = self.current_step(session)
+ if step is None:
+ raise DialogError("No current step.")
+ return step
+
+ def _resolve_next_index(
+ self,
+ step: DialogStep,
+ answer: Any,
+ current_index: int,
+ ) -> int | None:
+ """Compute the index of the next step.
+
+ Returns ``None`` to signal end-of-dialog.
+ """
+ if step.next is None:
+ # Sequential advancement.
+ nxt = current_index + 1
+ return nxt if nxt < len(self.steps) else None
+
+ if isinstance(step.next, str):
+ # Unconditional jump.
+ if step.next == "_end":
+ return None
+ return self._lookup(step.next)
+
+ # Conditional branch dict.
+ branch_map: dict[str, str] = step.next
+ key = str(answer) if answer is not None else "_default"
+ target_id = branch_map.get(key) or branch_map.get("_default")
+
+ if target_id is None:
+ # No matching branch → fall through sequentially.
+ nxt = current_index + 1
+ return nxt if nxt < len(self.steps) else None
+
+ if target_id == "_end":
+ return None
+
+ return self._lookup(target_id)
+
+ def _lookup(self, step_id: str) -> int:
+ idx = self._by_id.get(step_id)
+ if idx is None:
+ raise StepNotFoundError(step_id)
+ return idx
diff --git a/dialog_engine/exceptions.py b/dialog_engine/exceptions.py
new file mode 100644
index 0000000..d406a92
--- /dev/null
+++ b/dialog_engine/exceptions.py
@@ -0,0 +1,23 @@
+"""Exceptions for the dialog engine."""
+
+from __future__ import annotations
+
+
+class DialogError(Exception):
+ """Base exception for all dialog engine errors."""
+
+
+class StepNotFoundError(DialogError):
+ """Raised when a step ID cannot be resolved."""
+
+ def __init__(self, step_id: str) -> None:
+ super().__init__(f"Step {step_id!r} not found")
+ self.step_id = step_id
+
+
+class ValidationError(DialogError):
+ """Raised when a submitted answer fails validation."""
+
+ def __init__(self, message: str, step_id: str | None = None) -> None:
+ super().__init__(message)
+ self.step_id = step_id
diff --git a/dialog_engine/session.py b/dialog_engine/session.py
new file mode 100644
index 0000000..d68a1ed
--- /dev/null
+++ b/dialog_engine/session.py
@@ -0,0 +1,69 @@
+"""DialogSession — runtime state for one dialog run."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from enum import StrEnum
+from typing import Any
+
+
+class SessionStatus(StrEnum):
+ """Lifecycle state of a dialog session."""
+
+ IN_PROGRESS = "in_progress"
+ COMPLETED = "completed"
+ CANCELLED = "cancelled"
+
+
+@dataclass
+class DialogSession:
+ """Tracks the progress of a single dialog run.
+
+ Attributes:
+ dialog_id: ID of the :class:`~dialog_engine.DialogEngine` that owns this session.
+ answers: Collected answers keyed by step ID.
+ status: Current lifecycle state.
+
+ Internal:
+ _history: Stack of visited step indices. ``_history[-1]`` is always
+ the *current* (not yet answered) step.
+ """
+
+ dialog_id: str
+ answers: dict[str, Any] = field(default_factory=dict)
+ status: SessionStatus = SessionStatus.IN_PROGRESS
+ _history: list[int] = field(default_factory=list, repr=False)
+
+ # ── Properties ────────────────────────────────────────────────────────────
+
+ @property
+ def current_index(self) -> int:
+ """Zero-based index of the step the user is currently on."""
+ return self._history[-1] if self._history else 0
+
+ @property
+ def is_active(self) -> bool:
+ """``True`` while the session is in progress."""
+ return self.status == SessionStatus.IN_PROGRESS
+
+ # ── Serialisation ─────────────────────────────────────────────────────────
+
+ def to_dict(self) -> dict[str, Any]:
+ """Serialise session state (e.g. for storage in Redis / DB)."""
+ return {
+ "dialog_id": self.dialog_id,
+ "answers": self.answers,
+ "history": list(self._history),
+ "status": self.status.value,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> DialogSession:
+ """Restore a previously serialised session."""
+ session = cls(
+ dialog_id=data["dialog_id"],
+ answers=dict(data.get("answers", {})),
+ status=SessionStatus(data.get("status", SessionStatus.IN_PROGRESS)),
+ )
+ session._history = list(data.get("history", []))
+ return session
diff --git a/dialog_engine/step.py b/dialog_engine/step.py
new file mode 100644
index 0000000..2bc2464
--- /dev/null
+++ b/dialog_engine/step.py
@@ -0,0 +1,106 @@
+"""DialogStep — a single unit in a dialog flow."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Literal
+
+# ── Public type alias ────────────────────────────────────────────────────────
+
+StepType = Literal[
+ "text",
+ "number",
+ "email",
+ "boolean",
+ "choice",
+ "multi_choice",
+ "photo",
+ "file",
+]
+
+# ── Branching type ────────────────────────────────────────────────────────────
+#
+# next: None → advance sequentially
+# next: "step_id" → always jump to that step
+# next: {"KEY": "step_id", "_default": "step_id"}
+# → branch on the submitted answer key;
+# use "_end" as a value to terminate the dialog.
+
+NextSpec = str | dict[str, str] | None
+
+
+@dataclass
+class DialogStep:
+ """A single step in a dialog flow.
+
+ Attributes:
+ id: Unique identifier used for answer storage and branching.
+ type: Input type; determines which validator runs.
+ text: Display text or i18n key passed to the text resolver.
+ required: Whether the step must be answered (cannot be skipped).
+ choices: Mapping of key → display text for *choice* / *multi_choice* steps.
+ min: Lower bound. Meaning depends on type:
+ text → min character length
+ number → min numeric value
+ photo / file → min item count
+ multi_choice → min selected options
+ max: Upper bound (same semantics as *min*).
+ pattern: Regex pattern for *text* steps (applied via ``re.fullmatch``).
+ next: Branching spec (see module docstring).
+ meta: Arbitrary extra data; ignored by the engine.
+ """
+
+ id: str
+ type: StepType
+ text: str
+ required: bool = True
+ choices: dict[str, str] = field(default_factory=dict)
+ min: int | float | None = None
+ max: int | float | None = None
+ pattern: str | None = None
+ next: NextSpec = None
+ meta: dict[str, Any] = field(default_factory=dict)
+
+ # ── Construction ──────────────────────────────────────────────────────────
+
+ @classmethod
+ def from_dict(cls, data: dict) -> DialogStep:
+ """Create a step from a plain dict (e.g. parsed JSON).
+
+ Accepted keys mirror the field names; legacy ``min_photos`` /
+ ``max_photos`` are also recognised for backward compatibility.
+ """
+ return cls(
+ id=data["id"],
+ type=data["type"],
+ text=data["text"],
+ required=data.get("required", True),
+ choices=data.get("choices", {}),
+ min=data.get("min", data.get("min_photos")),
+ max=data.get("max", data.get("max_photos")),
+ pattern=data.get("pattern"),
+ next=data.get("next"),
+ meta=data.get("meta", {}),
+ )
+
+ def to_dict(self) -> dict[str, Any]:
+ """Serialise this step back to a plain dict."""
+ d: dict[str, Any] = {
+ "id": self.id,
+ "type": self.type,
+ "text": self.text,
+ "required": self.required,
+ }
+ if self.choices:
+ d["choices"] = self.choices
+ if self.min is not None:
+ d["min"] = self.min
+ if self.max is not None:
+ d["max"] = self.max
+ if self.pattern is not None:
+ d["pattern"] = self.pattern
+ if self.next is not None:
+ d["next"] = self.next
+ if self.meta:
+ d["meta"] = self.meta
+ return d
diff --git a/dialog_engine/validators.py b/dialog_engine/validators.py
new file mode 100644
index 0000000..c98e14a
--- /dev/null
+++ b/dialog_engine/validators.py
@@ -0,0 +1,200 @@
+"""Built-in answer validators for each step type.
+
+Each validator:
+ - accepts (value, step) where *value* is the raw user input
+ - returns a cleaned / normalised value on success
+ - raises ValidationError on failure
+"""
+
+from __future__ import annotations
+
+import asyncio
+import re
+from collections.abc import Awaitable, Callable
+from typing import Any
+
+from .exceptions import ValidationError
+from .step import DialogStep
+
+# ── Per-type validators ───────────────────────────────────────────────────────
+
+
+def _validate_text(value: Any, step: DialogStep) -> str:
+ text = str(value).strip()
+ if not text:
+ if step.required:
+ raise ValidationError("Это поле обязательно для заполнения.", step.id)
+ return text
+ if step.min is not None and len(text) < int(step.min):
+ raise ValidationError(
+ f"Слишком короткий текст (минимум {int(step.min)} символов).", step.id
+ )
+ if step.max is not None and len(text) > int(step.max):
+ raise ValidationError(
+ f"Слишком длинный текст (максимум {int(step.max)} символов).", step.id
+ )
+ if step.pattern is not None and not re.fullmatch(step.pattern, text):
+ raise ValidationError("Текст не соответствует ожидаемому формату.", step.id)
+ return text
+
+
+def _validate_number(value: Any, step: DialogStep) -> int | float:
+ try:
+ num = float(str(value).replace(",", "."))
+ except (TypeError, ValueError):
+ raise ValidationError(
+ f"Ожидается число, получено: {value!r}.", step.id
+ ) from None
+ if step.min is not None and num < step.min:
+ raise ValidationError(f"Число должно быть не меньше {step.min}.", step.id)
+ if step.max is not None and num > step.max:
+ raise ValidationError(f"Число должно быть не больше {step.max}.", step.id)
+ return int(num) if num == int(num) else num
+
+
+def _validate_email(value: Any, step: DialogStep) -> str:
+ text = str(value).strip().lower()
+ if not re.fullmatch(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", text):
+ raise ValidationError(
+ f"Некорректный адрес электронной почты: {text!r}.", step.id
+ )
+ return text
+
+
+def _validate_boolean(value: Any, step: DialogStep) -> bool:
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, int) and value in (0, 1):
+ return bool(value)
+ if isinstance(value, str):
+ if value.lower() in ("1", "true", "yes", "да", "y"):
+ return True
+ if value.lower() in ("0", "false", "no", "нет", "n"):
+ return False
+ raise ValidationError(
+ f"Ожидается булево значение (true/false/да/нет), получено: {value!r}.", step.id
+ )
+
+
+def _validate_choice(value: Any, step: DialogStep) -> str:
+ key = str(value)
+ if key not in step.choices:
+ valid = ", ".join(step.choices.keys())
+ raise ValidationError(
+ f"Неверный вариант: {key!r}. Допустимые: {valid}.", step.id
+ )
+ return key
+
+
+def _validate_multi_choice(value: Any, step: DialogStep) -> list[str]:
+ if isinstance(value, str):
+ items = [v.strip() for v in value.split(",") if v.strip()]
+ elif isinstance(value, (list, tuple, set)):
+ items = [str(v) for v in value]
+ else:
+ raise ValidationError(
+ f"Ожидается список вариантов, получено: {value!r}.", step.id
+ )
+
+ invalid = [v for v in items if v not in step.choices]
+ if invalid:
+ valid = ", ".join(step.choices.keys())
+ raise ValidationError(
+ f"Недопустимые варианты: {invalid}. Допустимые: {valid}.", step.id
+ )
+ if step.min is not None and len(items) < int(step.min):
+ raise ValidationError(f"Выберите не менее {int(step.min)} вариантов.", step.id)
+ if step.max is not None and len(items) > int(step.max):
+ raise ValidationError(f"Выберите не более {int(step.max)} вариантов.", step.id)
+ return items
+
+
+def _validate_media(value: Any, step: DialogStep, label: str) -> list[Any]:
+ items: list[Any] = value if isinstance(value, list) else [value]
+ min_count = int(step.min) if step.min is not None else 1
+ max_count = int(step.max) if step.max is not None else 1
+ if len(items) < min_count:
+ raise ValidationError(
+ f"Необходимо загрузить минимум {min_count} {label}.", step.id
+ )
+ if len(items) > max_count:
+ raise ValidationError(f"Можно загрузить не более {max_count} {label}.", step.id)
+ return items
+
+
+def _validate_photo(value: Any, step: DialogStep) -> list[Any]:
+ return _validate_media(value, step, "фото")
+
+
+def _validate_file(value: Any, step: DialogStep) -> list[Any]:
+ return _validate_media(value, step, "файлов")
+
+
+# ── Registry ──────────────────────────────────────────────────────────────────
+
+AsyncValidator = Callable[[Any, DialogStep], Awaitable[Any]]
+
+_VALIDATORS: dict[str, Any] = {
+ "text": _validate_text,
+ "number": _validate_number,
+ "email": _validate_email,
+ "boolean": _validate_boolean,
+ "choice": _validate_choice,
+ "multi_choice": _validate_multi_choice,
+ "photo": _validate_photo,
+ "file": _validate_file,
+}
+
+_ASYNC_VALIDATORS: dict[str, AsyncValidator] = {}
+
+
+def sync_validate(step: DialogStep, value: Any) -> Any:
+ """Validate *value* against *step*'s rules synchronously.
+
+ Returns the cleaned value on success.
+ Raises :class:`ValidationError` on failure.
+ Unknown step types pass through without validation.
+ """
+ is_empty = value is None or (isinstance(value, str) and not value.strip())
+ if is_empty:
+ if step.required:
+ raise ValidationError("Это поле обязательно для заполнения.", step.id)
+ return value # optional → accept empty
+
+ fn = _VALIDATORS.get(step.type)
+ if fn is None:
+ return value
+ return fn(value, step)
+
+
+def validate(step: DialogStep, value: Any) -> Any:
+ """Validate *value* against *step*'s rules.
+
+ Returns the cleaned value on success.
+ Raises :class:`ValidationError` on failure.
+ Unknown step types pass through without validation.
+ """
+ return sync_validate(step, value)
+
+
+async def async_validate(step: DialogStep, value: Any) -> Any:
+ """Validate *value* against *step*'s rules asynchronously.
+
+ Returns the cleaned value on success.
+ Raises :class:`ValidationError` on failure.
+ Unknown step types pass through without validation.
+ """
+ is_empty = value is None or (isinstance(value, str) and not value.strip())
+ if is_empty:
+ if step.required:
+ raise ValidationError("Это поле обязательно для заполнения.", step.id)
+ return value # optional → accept empty
+
+ fn = _ASYNC_VALIDATORS.get(step.type) or _VALIDATORS.get(step.type)
+ if fn is None:
+ return value
+
+ if asyncio.iscoroutinefunction(fn):
+ return await fn(value, step)
+ else:
+ return fn(value, step)
diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md
new file mode 100644
index 0000000..420e618
--- /dev/null
+++ b/docs/WORKFLOW.md
@@ -0,0 +1,262 @@
+# Workflow: как это всё работает
+
+Документ описывает полный цикл — от первого `git commit` до GitHub Release — и
+как организовать работу через feature-ветки.
+
+> Все диаграммы написаны на [Mermaid](https://mermaid.js.org/). GitHub
+> рендерит их автоматически.
+
+---
+
+## 1. Большая картина
+
+```mermaid
+flowchart TD
+ A[Локальные правки] --> B[git commit]
+ B --> C{pre-commit hooks}
+ C -- ruff / ruff-format --> C
+ C -- commit-msg
commitizen --> D{Conventional
Commits?}
+ D -- нет --> X[Коммит отклонён
исправить сообщение]
+ D -- да --> E[Commit создан локально]
+ E --> F[git push в feature-ветку]
+ F --> G[Pull Request на GitHub]
+ G --> H[CI: python-app.yml
pre-commit + pytest]
+ H -- зелёный --> I[Merge в main]
+ I --> J[CI: release-please.yml]
+ J --> K[release-please открывает/
обновляет Release PR]
+ K -- merge Release PR --> L[Tag vX.Y.Z + GitHub Release
+ CHANGELOG.md]
+
+ classDef bad fill:#fee,stroke:#c33;
+ class X bad;
+```
+
+Главное: **сообщения коммитов — это и есть «исходник» changelog'а**. Поэтому их
+формат жёстко проверяется на этапе `git commit`, до того как код попадёт на
+сервер, а release-please превращает их в версию и CHANGELOG.
+
+---
+
+## 2. Conventional Commits — формат сообщений
+
+```
+()!:
+
+
+
+