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:
129
meeting_room/events.py
Normal file
129
meeting_room/events.py
Normal 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
124
meeting_room/models.py
Normal 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),
|
||||
)
|
||||
Reference in New Issue
Block a user