feat: add Pydantic models and EventEmitter for v2 core

- models.py: ProviderConfig, RoleConfig, DiscussionConfig, ScenarioConfig,
  Message, ToolCallRecord, Session — compatible with config.yaml format
- events.py: Event constants, Event dataclass, EventEmitter with on/off/emit
- 67 tests passing (37 models + 30 events)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 00:00:21 +03:00
parent 1064276e81
commit d292d32afa
4 changed files with 960 additions and 0 deletions

129
meeting_room/events.py Normal file
View File

@@ -0,0 +1,129 @@
"""EventEmitter and event types for the Meeting Room discussion engine.
Synchronous event bus. In Phase 2, an AsyncEventBridge will forward
events to WebSocket clients.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Callable
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Event type constants
# ---------------------------------------------------------------------------
DISCUSSION_START = "discussion_start"
ROUND_START = "round_start"
AGENT_TURN_START = "agent_turn_start"
AGENT_MESSAGE = "agent_message"
TOOL_CALL = "tool_call"
TOOL_RESULT = "tool_result"
AGENT_ERROR = "agent_error"
ROUND_END = "round_end"
DISCUSSION_END = "discussion_end"
BOSS_TURN = "boss_turn"
# ---------------------------------------------------------------------------
# Event dataclass
# ---------------------------------------------------------------------------
@dataclass
class Event:
"""A single event emitted by the discussion engine."""
type: str
data: dict[str, Any] = field(default_factory=dict)
timestamp: datetime = field(default_factory=datetime.now)
# ---------------------------------------------------------------------------
# EventEmitter
# ---------------------------------------------------------------------------
# Type alias for handler callbacks
Handler = Callable[[Event], None]
class EventEmitter:
"""Synchronous pub/sub event bus.
Usage::
bus = EventEmitter()
def on_message(event: Event):
print(event.data)
bus.on(AGENT_MESSAGE, on_message)
bus.emit(AGENT_MESSAGE, text="Hello", agent="Alice")
bus.off(AGENT_MESSAGE, on_message)
"""
def __init__(self) -> None:
self._handlers: dict[str, list[Handler]] = {}
# -- subscription --------------------------------------------------------
def on(self, event_type: str, handler: Handler) -> Handler:
"""Subscribe *handler* to *event_type*.
Returns the handler so callers can use ``off`` with the same
reference, or chain calls.
"""
self._handlers.setdefault(event_type, []).append(handler)
return handler
def off(self, event_type: str, handler: Handler) -> None:
"""Unsubscribe *handler* from *event_type*.
Silently ignores unknown event types or missing handlers.
"""
handlers = self._handlers.get(event_type)
if handlers is None:
return
try:
handlers.remove(handler)
except ValueError:
pass
# Clean up empty lists to avoid unbounded growth
if not handlers:
del self._handlers[event_type]
# -- emission ------------------------------------------------------------
def emit(self, event_type: str, **data: Any) -> Event:
"""Create an Event and call every subscribed handler synchronously.
Handlers that raise exceptions are caught and logged; the emitter
never crashes due to a bad handler.
Returns the Event that was emitted (useful for testing).
"""
event = Event(type=event_type, data=data)
handlers = self._handlers.get(event_type, [])
for handler in handlers:
try:
handler(event)
except Exception:
logger.exception(
"Handler %r raised for event %s",
handler,
event_type,
)
return event
# -- introspection -------------------------------------------------------
def handlers(self, event_type: str) -> list[Handler]:
"""Return a *copy* of the handler list for *event_type*."""
return list(self._handlers.get(event_type, []))
def clear(self) -> None:
"""Remove all subscriptions."""
self._handlers.clear()

124
meeting_room/models.py Normal file
View File

@@ -0,0 +1,124 @@
"""Pydantic v2 data models for Meeting Room."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Union
from pydantic import BaseModel, ConfigDict, Field, field_validator
# ---------------------------------------------------------------------------
# Configuration models — must be compatible with config.yaml parsed by
# yaml.safe_load() so that DiscussionConfig(**yaml_config) works unchanged.
# ---------------------------------------------------------------------------
class ProviderConfig(BaseModel):
"""A single API provider (OpenAI-compatible endpoint)."""
model_config = ConfigDict(extra="forbid")
base_url: str
api_key: str
class RoleConfig(BaseModel):
"""A discussion participant role bound to a provider/model."""
model_config = ConfigDict(extra="forbid")
name: str
provider: str
model: str
temperature: float = 0.7
tools: Union[str, list[str]] = "none"
system_prompt: str = ""
@field_validator("tools", mode="before")
@classmethod
def _coerce_tools(cls, v: object) -> Union[str, list[str]]:
if isinstance(v, str):
return v
if isinstance(v, list):
return [str(item) for item in v]
raise ValueError(f"tools must be a string or list of strings, got {type(v).__name__}")
class DefaultsConfig(BaseModel):
"""Default settings inherited by scenarios."""
model_config = ConfigDict(extra="ignore")
max_rounds: int = 8
language: str = "ru"
framework: str = "custom"
workdir: str = "."
class DiscussionConfig(BaseModel):
"""Top-level config — the direct counterpart of config.yaml."""
model_config = ConfigDict(extra="ignore")
providers: dict[str, ProviderConfig] = Field(default_factory=dict)
roles: dict[str, RoleConfig] = Field(default_factory=dict)
defaults: DefaultsConfig = Field(default_factory=DefaultsConfig)
# ---------------------------------------------------------------------------
# Scenario model
# ---------------------------------------------------------------------------
class ScenarioConfig(BaseModel):
"""A discussion scenario (loaded from .md / .yaml files)."""
model_config = ConfigDict(extra="ignore")
name: str = "Untitled"
participants: list[str] = Field(default_factory=list)
max_rounds: int = 8
problem: str = ""
# ---------------------------------------------------------------------------
# Runtime models
# ---------------------------------------------------------------------------
class Message(BaseModel):
"""A single message in a discussion session."""
model_config = ConfigDict(extra="forbid")
role_id: str
name: str
content: str
class ToolCallRecord(BaseModel):
"""Record of a single tool call made by an agent."""
model_config = ConfigDict(extra="forbid")
id: str
name: str
arguments: str
result: str = ""
class Session(BaseModel):
"""A complete discussion session (scenario + messages + timestamps)."""
model_config = ConfigDict(extra="ignore")
scenario: ScenarioConfig = Field(default_factory=ScenarioConfig)
messages: list[Message] = Field(default_factory=list)
tool_calls: list[ToolCallRecord] = Field(default_factory=list)
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
)
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
)