roomkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- roomkit/AGENTS.md +362 -0
- roomkit/__init__.py +372 -0
- roomkit/_version.py +1 -0
- roomkit/ai_docs.py +93 -0
- roomkit/channels/__init__.py +194 -0
- roomkit/channels/ai.py +238 -0
- roomkit/channels/base.py +66 -0
- roomkit/channels/transport.py +115 -0
- roomkit/channels/websocket.py +85 -0
- roomkit/core/__init__.py +0 -0
- roomkit/core/_channel_ops.py +252 -0
- roomkit/core/_helpers.py +296 -0
- roomkit/core/_inbound.py +435 -0
- roomkit/core/_room_lifecycle.py +275 -0
- roomkit/core/circuit_breaker.py +84 -0
- roomkit/core/event_router.py +401 -0
- roomkit/core/framework.py +793 -0
- roomkit/core/hooks.py +232 -0
- roomkit/core/inbound_router.py +57 -0
- roomkit/core/locks.py +66 -0
- roomkit/core/rate_limiter.py +67 -0
- roomkit/core/retry.py +49 -0
- roomkit/core/router.py +24 -0
- roomkit/core/transcoder.py +85 -0
- roomkit/identity/__init__.py +0 -0
- roomkit/identity/base.py +27 -0
- roomkit/identity/mock.py +49 -0
- roomkit/llms.txt +52 -0
- roomkit/models/__init__.py +104 -0
- roomkit/models/channel.py +99 -0
- roomkit/models/context.py +35 -0
- roomkit/models/delivery.py +76 -0
- roomkit/models/enums.py +170 -0
- roomkit/models/event.py +203 -0
- roomkit/models/framework_event.py +19 -0
- roomkit/models/hook.py +68 -0
- roomkit/models/identity.py +81 -0
- roomkit/models/participant.py +34 -0
- roomkit/models/room.py +33 -0
- roomkit/models/task.py +36 -0
- roomkit/providers/__init__.py +0 -0
- roomkit/providers/ai/__init__.py +0 -0
- roomkit/providers/ai/base.py +140 -0
- roomkit/providers/ai/mock.py +33 -0
- roomkit/providers/anthropic/__init__.py +6 -0
- roomkit/providers/anthropic/ai.py +145 -0
- roomkit/providers/anthropic/config.py +14 -0
- roomkit/providers/elasticemail/__init__.py +6 -0
- roomkit/providers/elasticemail/config.py +16 -0
- roomkit/providers/elasticemail/email.py +97 -0
- roomkit/providers/email/__init__.py +0 -0
- roomkit/providers/email/base.py +46 -0
- roomkit/providers/email/mock.py +34 -0
- roomkit/providers/gemini/__init__.py +6 -0
- roomkit/providers/gemini/ai.py +153 -0
- roomkit/providers/gemini/config.py +14 -0
- roomkit/providers/http/__init__.py +15 -0
- roomkit/providers/http/base.py +33 -0
- roomkit/providers/http/config.py +14 -0
- roomkit/providers/http/mock.py +21 -0
- roomkit/providers/http/provider.py +105 -0
- roomkit/providers/http/webhook.py +33 -0
- roomkit/providers/messenger/__init__.py +15 -0
- roomkit/providers/messenger/base.py +33 -0
- roomkit/providers/messenger/config.py +17 -0
- roomkit/providers/messenger/facebook.py +95 -0
- roomkit/providers/messenger/mock.py +21 -0
- roomkit/providers/messenger/webhook.py +42 -0
- roomkit/providers/openai/__init__.py +6 -0
- roomkit/providers/openai/ai.py +155 -0
- roomkit/providers/openai/config.py +24 -0
- roomkit/providers/pydantic_ai/__init__.py +5 -0
- roomkit/providers/pydantic_ai/config.py +14 -0
- roomkit/providers/rcs/__init__.py +9 -0
- roomkit/providers/rcs/base.py +95 -0
- roomkit/providers/rcs/mock.py +78 -0
- roomkit/providers/sendgrid/__init__.py +5 -0
- roomkit/providers/sendgrid/config.py +13 -0
- roomkit/providers/sinch/__init__.py +6 -0
- roomkit/providers/sinch/config.py +22 -0
- roomkit/providers/sinch/sms.py +192 -0
- roomkit/providers/sms/__init__.py +15 -0
- roomkit/providers/sms/base.py +67 -0
- roomkit/providers/sms/meta.py +401 -0
- roomkit/providers/sms/mock.py +24 -0
- roomkit/providers/sms/phone.py +77 -0
- roomkit/providers/telnyx/__init__.py +21 -0
- roomkit/providers/telnyx/config.py +14 -0
- roomkit/providers/telnyx/rcs.py +352 -0
- roomkit/providers/telnyx/sms.py +231 -0
- roomkit/providers/twilio/__init__.py +18 -0
- roomkit/providers/twilio/config.py +19 -0
- roomkit/providers/twilio/rcs.py +183 -0
- roomkit/providers/twilio/sms.py +200 -0
- roomkit/providers/voicemeup/__init__.py +15 -0
- roomkit/providers/voicemeup/config.py +21 -0
- roomkit/providers/voicemeup/sms.py +374 -0
- roomkit/providers/whatsapp/__init__.py +0 -0
- roomkit/providers/whatsapp/base.py +44 -0
- roomkit/providers/whatsapp/mock.py +21 -0
- roomkit/py.typed +0 -0
- roomkit/realtime/__init__.py +17 -0
- roomkit/realtime/base.py +111 -0
- roomkit/realtime/memory.py +158 -0
- roomkit/sources/__init__.py +35 -0
- roomkit/sources/base.py +207 -0
- roomkit/sources/websocket.py +260 -0
- roomkit/store/__init__.py +0 -0
- roomkit/store/base.py +230 -0
- roomkit/store/memory.py +293 -0
- roomkit-0.1.0.dist-info/METADATA +567 -0
- roomkit-0.1.0.dist-info/RECORD +114 -0
- roomkit-0.1.0.dist-info/WHEEL +4 -0
- roomkit-0.1.0.dist-info/licenses/LICENSE +21 -0
roomkit/models/event.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Event and content models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Annotated, Any, Literal
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
10
|
+
|
|
11
|
+
from roomkit.models.enums import (
|
|
12
|
+
ChannelDirection,
|
|
13
|
+
ChannelType,
|
|
14
|
+
EventStatus,
|
|
15
|
+
EventType,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TextContent(BaseModel):
|
|
20
|
+
"""Plain text message content."""
|
|
21
|
+
|
|
22
|
+
type: Literal["text"] = "text"
|
|
23
|
+
body: str
|
|
24
|
+
language: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RichContent(BaseModel):
|
|
28
|
+
"""Rich formatted content (HTML/Markdown)."""
|
|
29
|
+
|
|
30
|
+
type: Literal["rich"] = "rich"
|
|
31
|
+
body: str
|
|
32
|
+
format: Literal["html", "markdown"] = "markdown"
|
|
33
|
+
plain_text: str | None = None
|
|
34
|
+
buttons: list[dict[str, Any]] = Field(default_factory=list)
|
|
35
|
+
cards: list[dict[str, Any]] = Field(default_factory=list)
|
|
36
|
+
quick_replies: list[str] = Field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MediaContent(BaseModel):
|
|
40
|
+
"""Media attachment content."""
|
|
41
|
+
|
|
42
|
+
type: Literal["media"] = "media"
|
|
43
|
+
url: str
|
|
44
|
+
mime_type: str
|
|
45
|
+
filename: str | None = None
|
|
46
|
+
size_bytes: int | None = Field(default=None, ge=0)
|
|
47
|
+
caption: str | None = None
|
|
48
|
+
|
|
49
|
+
@field_validator("url")
|
|
50
|
+
@classmethod
|
|
51
|
+
def _validate_url(cls, v: str) -> str:
|
|
52
|
+
if not v.startswith(("http://", "https://")):
|
|
53
|
+
raise ValueError("URL must start with http:// or https://")
|
|
54
|
+
return v
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LocationContent(BaseModel):
|
|
58
|
+
"""Geographic location content."""
|
|
59
|
+
|
|
60
|
+
type: Literal["location"] = "location"
|
|
61
|
+
latitude: float = Field(ge=-90.0, le=90.0)
|
|
62
|
+
longitude: float = Field(ge=-180.0, le=180.0)
|
|
63
|
+
label: str | None = None
|
|
64
|
+
address: str | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AudioContent(BaseModel):
|
|
68
|
+
"""Audio message content."""
|
|
69
|
+
|
|
70
|
+
type: Literal["audio"] = "audio"
|
|
71
|
+
url: str
|
|
72
|
+
mime_type: str = "audio/ogg"
|
|
73
|
+
duration_seconds: float | None = Field(default=None, ge=0.0)
|
|
74
|
+
transcript: str | None = None
|
|
75
|
+
|
|
76
|
+
@field_validator("url")
|
|
77
|
+
@classmethod
|
|
78
|
+
def _validate_url(cls, v: str) -> str:
|
|
79
|
+
if not v.startswith(("http://", "https://")):
|
|
80
|
+
raise ValueError("URL must start with http:// or https://")
|
|
81
|
+
return v
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class VideoContent(BaseModel):
|
|
85
|
+
"""Video message content."""
|
|
86
|
+
|
|
87
|
+
type: Literal["video"] = "video"
|
|
88
|
+
url: str
|
|
89
|
+
mime_type: str = "video/mp4"
|
|
90
|
+
duration_seconds: float | None = Field(default=None, ge=0.0)
|
|
91
|
+
thumbnail_url: str | None = None
|
|
92
|
+
|
|
93
|
+
@field_validator("url", "thumbnail_url")
|
|
94
|
+
@classmethod
|
|
95
|
+
def _validate_url(cls, v: str | None) -> str | None:
|
|
96
|
+
if v is not None and not v.startswith(("http://", "https://")):
|
|
97
|
+
raise ValueError("URL must start with http:// or https://")
|
|
98
|
+
return v
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class CompositeContent(BaseModel):
|
|
102
|
+
"""Multi-part content combining multiple content types."""
|
|
103
|
+
|
|
104
|
+
type: Literal["composite"] = "composite"
|
|
105
|
+
parts: list[EventContent]
|
|
106
|
+
|
|
107
|
+
@model_validator(mode="after")
|
|
108
|
+
def _validate_parts(self) -> CompositeContent:
|
|
109
|
+
if not self.parts:
|
|
110
|
+
raise ValueError("CompositeContent must have at least one part")
|
|
111
|
+
depth = self._nesting_depth(self)
|
|
112
|
+
if depth > 5:
|
|
113
|
+
raise ValueError(f"CompositeContent nesting depth {depth} exceeds maximum of 5")
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _nesting_depth(content: object, current: int = 1) -> int:
|
|
118
|
+
"""Recursively compute nesting depth of CompositeContent."""
|
|
119
|
+
if not isinstance(content, CompositeContent):
|
|
120
|
+
return 0
|
|
121
|
+
max_child = 0
|
|
122
|
+
for part in content.parts:
|
|
123
|
+
child_depth = CompositeContent._nesting_depth(part, current + 1)
|
|
124
|
+
if child_depth > max_child:
|
|
125
|
+
max_child = child_depth
|
|
126
|
+
return 1 + max_child
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class SystemContent(BaseModel):
|
|
130
|
+
"""System-generated content."""
|
|
131
|
+
|
|
132
|
+
type: Literal["system"] = "system"
|
|
133
|
+
body: str
|
|
134
|
+
code: str | None = None
|
|
135
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class TemplateContent(BaseModel):
|
|
139
|
+
"""Pre-approved template content (WhatsApp Business, etc.)."""
|
|
140
|
+
|
|
141
|
+
type: Literal["template"] = "template"
|
|
142
|
+
template_id: str
|
|
143
|
+
language: str = "en"
|
|
144
|
+
parameters: dict[str, str] = Field(default_factory=dict)
|
|
145
|
+
body: str | None = None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
EventContent = Annotated[
|
|
149
|
+
TextContent
|
|
150
|
+
| RichContent
|
|
151
|
+
| MediaContent
|
|
152
|
+
| LocationContent
|
|
153
|
+
| AudioContent
|
|
154
|
+
| VideoContent
|
|
155
|
+
| CompositeContent
|
|
156
|
+
| SystemContent
|
|
157
|
+
| TemplateContent,
|
|
158
|
+
Field(discriminator="type"),
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class ChannelData(BaseModel):
|
|
163
|
+
"""Provider-specific channel metadata."""
|
|
164
|
+
|
|
165
|
+
provider: str | None = None
|
|
166
|
+
external_id: str | None = None
|
|
167
|
+
thread_id: str | None = None
|
|
168
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class EventSource(BaseModel):
|
|
172
|
+
"""Origin information for an event."""
|
|
173
|
+
|
|
174
|
+
channel_id: str
|
|
175
|
+
channel_type: ChannelType
|
|
176
|
+
direction: ChannelDirection = ChannelDirection.INBOUND
|
|
177
|
+
participant_id: str | None = None
|
|
178
|
+
external_id: str | None = None
|
|
179
|
+
provider: str | None = None
|
|
180
|
+
raw_payload: dict[str, Any] = Field(default_factory=dict)
|
|
181
|
+
provider_message_id: str | None = None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class RoomEvent(BaseModel):
|
|
185
|
+
"""A single event in a room conversation."""
|
|
186
|
+
|
|
187
|
+
id: str = Field(default_factory=lambda: uuid4().hex)
|
|
188
|
+
room_id: str
|
|
189
|
+
type: EventType = EventType.MESSAGE
|
|
190
|
+
source: EventSource
|
|
191
|
+
content: EventContent
|
|
192
|
+
status: EventStatus = EventStatus.PENDING
|
|
193
|
+
blocked_by: str | None = None
|
|
194
|
+
visibility: str = "all"
|
|
195
|
+
index: int = Field(default=0, ge=0)
|
|
196
|
+
chain_depth: int = Field(default=0, ge=0)
|
|
197
|
+
parent_event_id: str | None = None
|
|
198
|
+
correlation_id: str | None = None
|
|
199
|
+
idempotency_key: str | None = None
|
|
200
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
201
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
202
|
+
channel_data: ChannelData = Field(default_factory=ChannelData)
|
|
203
|
+
delivery_results: dict[str, Any] = Field(default_factory=dict)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Framework-level event model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FrameworkEvent(BaseModel):
|
|
12
|
+
"""An event emitted by the framework for observability."""
|
|
13
|
+
|
|
14
|
+
type: str
|
|
15
|
+
room_id: str | None = None
|
|
16
|
+
channel_id: str | None = None
|
|
17
|
+
event_id: str | None = None
|
|
18
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
19
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
roomkit/models/hook.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Hook-related models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, model_validator
|
|
8
|
+
|
|
9
|
+
from roomkit.models.event import RoomEvent
|
|
10
|
+
from roomkit.models.task import Observation, Task
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InjectedEvent(BaseModel):
|
|
14
|
+
"""An event injected by a hook as a side effect."""
|
|
15
|
+
|
|
16
|
+
event: RoomEvent
|
|
17
|
+
target_channel_ids: list[str] | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HookResult(BaseModel):
|
|
21
|
+
"""Result returned by a sync hook."""
|
|
22
|
+
|
|
23
|
+
action: Literal["allow", "block", "modify"]
|
|
24
|
+
event: RoomEvent | None = None
|
|
25
|
+
reason: str | None = None
|
|
26
|
+
injected_events: list[InjectedEvent] = Field(default_factory=list)
|
|
27
|
+
tasks: list[Task] = Field(default_factory=list)
|
|
28
|
+
observations: list[Observation] = Field(default_factory=list)
|
|
29
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
@model_validator(mode="after")
|
|
32
|
+
def _validate_action_fields(self) -> HookResult:
|
|
33
|
+
if self.action == "modify" and self.event is None:
|
|
34
|
+
raise ValueError("action='modify' requires 'event' to be set")
|
|
35
|
+
if self.action == "block" and self.reason is None:
|
|
36
|
+
raise ValueError("action='block' requires 'reason' to be set")
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def allow(cls, injected: list[InjectedEvent] | None = None) -> HookResult:
|
|
41
|
+
"""Allow the event to proceed."""
|
|
42
|
+
return cls(action="allow", injected_events=injected or [])
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def block(
|
|
46
|
+
cls,
|
|
47
|
+
reason: str,
|
|
48
|
+
injected: list[InjectedEvent] | None = None,
|
|
49
|
+
tasks: list[Task] | None = None,
|
|
50
|
+
observations: list[Observation] | None = None,
|
|
51
|
+
) -> HookResult:
|
|
52
|
+
"""Block the event from proceeding."""
|
|
53
|
+
return cls(
|
|
54
|
+
action="block",
|
|
55
|
+
reason=reason,
|
|
56
|
+
injected_events=injected or [],
|
|
57
|
+
tasks=tasks or [],
|
|
58
|
+
observations=observations or [],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def modify(
|
|
63
|
+
cls,
|
|
64
|
+
event: RoomEvent,
|
|
65
|
+
injected: list[InjectedEvent] | None = None,
|
|
66
|
+
) -> HookResult:
|
|
67
|
+
"""Modify the event before it proceeds."""
|
|
68
|
+
return cls(action="modify", event=event, injected_events=injected or [])
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Identity and identification models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from roomkit.models.enums import IdentificationStatus
|
|
10
|
+
from roomkit.models.hook import InjectedEvent
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Identity(BaseModel):
|
|
14
|
+
"""A resolved user identity."""
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
organization_id: str | None = None
|
|
18
|
+
display_name: str | None = None
|
|
19
|
+
email: str | None = None
|
|
20
|
+
phone: str | None = None
|
|
21
|
+
channel_addresses: dict[str, list[str]] = Field(default_factory=dict)
|
|
22
|
+
external_id: str | None = None
|
|
23
|
+
external_ids: dict[str, str] = Field(default_factory=dict)
|
|
24
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class IdentityResult(BaseModel):
|
|
28
|
+
"""Result of identity resolution."""
|
|
29
|
+
|
|
30
|
+
status: IdentificationStatus
|
|
31
|
+
identity: Identity | None = None
|
|
32
|
+
candidates: list[Identity] = Field(default_factory=list)
|
|
33
|
+
address: str | None = None
|
|
34
|
+
channel_type: str | None = None
|
|
35
|
+
challenge_type: str | None = None
|
|
36
|
+
message: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class IdentityHookResult(BaseModel):
|
|
40
|
+
"""Result from an identity resolution hook."""
|
|
41
|
+
|
|
42
|
+
status: IdentificationStatus
|
|
43
|
+
identity: Identity | None = None
|
|
44
|
+
display_name: str | None = None
|
|
45
|
+
candidates: list[Identity] | None = None
|
|
46
|
+
inject: InjectedEvent | None = None
|
|
47
|
+
reason: str | None = None
|
|
48
|
+
challenge_type: str | None = None
|
|
49
|
+
message: str | None = None
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def resolved(cls, identity: Identity) -> IdentityHookResult:
|
|
53
|
+
"""Resolved - we know who this is."""
|
|
54
|
+
return cls(status=IdentificationStatus.IDENTIFIED, identity=identity)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def pending(
|
|
58
|
+
cls,
|
|
59
|
+
display_name: str | None = None,
|
|
60
|
+
candidates: list[Identity] | None = None,
|
|
61
|
+
) -> IdentityHookResult:
|
|
62
|
+
"""Pending - create participant with status=pending. Advisor resolves later."""
|
|
63
|
+
return cls(
|
|
64
|
+
status=IdentificationStatus.PENDING,
|
|
65
|
+
display_name=display_name,
|
|
66
|
+
candidates=candidates,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def challenge(cls, inject: InjectedEvent, message: str | None = None) -> IdentityHookResult:
|
|
71
|
+
"""Challenge - hold the message, ask the sender to self-identify."""
|
|
72
|
+
return cls(
|
|
73
|
+
status=IdentificationStatus.CHALLENGE_SENT,
|
|
74
|
+
inject=inject,
|
|
75
|
+
message=message,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def reject(cls, reason: str = "Unknown sender") -> IdentityHookResult:
|
|
80
|
+
"""Reject - do not create room or participant."""
|
|
81
|
+
return cls(status=IdentificationStatus.REJECTED, reason=reason)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Participant model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from roomkit.models.enums import (
|
|
11
|
+
IdentificationStatus,
|
|
12
|
+
ParticipantRole,
|
|
13
|
+
ParticipantStatus,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Participant(BaseModel):
|
|
18
|
+
"""A participant in a room conversation."""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
room_id: str
|
|
22
|
+
channel_id: str
|
|
23
|
+
display_name: str | None = None
|
|
24
|
+
role: ParticipantRole = ParticipantRole.MEMBER
|
|
25
|
+
status: ParticipantStatus = ParticipantStatus.ACTIVE
|
|
26
|
+
identification: IdentificationStatus = IdentificationStatus.PENDING
|
|
27
|
+
identity_id: str | None = None
|
|
28
|
+
candidates: list[str] | None = None
|
|
29
|
+
connected_via: list[str] = Field(default_factory=list)
|
|
30
|
+
external_id: str | None = None
|
|
31
|
+
resolved_at: datetime | None = None
|
|
32
|
+
resolved_by: str | None = None
|
|
33
|
+
joined_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
34
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
roomkit/models/room.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Room model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from roomkit.models.enums import RoomStatus
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RoomTimers(BaseModel):
|
|
14
|
+
"""Timer configuration for a room."""
|
|
15
|
+
|
|
16
|
+
inactive_after_seconds: int | None = Field(default=None, ge=0)
|
|
17
|
+
closed_after_seconds: int | None = Field(default=None, ge=0)
|
|
18
|
+
last_activity_at: datetime | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Room(BaseModel):
|
|
22
|
+
"""A conversation room."""
|
|
23
|
+
|
|
24
|
+
id: str
|
|
25
|
+
organization_id: str | None = None
|
|
26
|
+
status: RoomStatus = RoomStatus.ACTIVE
|
|
27
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
28
|
+
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
29
|
+
closed_at: datetime | None = None
|
|
30
|
+
timers: RoomTimers = Field(default_factory=RoomTimers)
|
|
31
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
32
|
+
event_count: int = Field(default=0, ge=0)
|
|
33
|
+
latest_index: int = Field(default=0, ge=0)
|
roomkit/models/task.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Task and observation models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from roomkit.models.enums import TaskStatus
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Task(BaseModel):
|
|
14
|
+
"""A task assigned within a room."""
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
room_id: str
|
|
18
|
+
title: str
|
|
19
|
+
description: str | None = None
|
|
20
|
+
assigned_to: str | None = None
|
|
21
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
22
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
23
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Observation(BaseModel):
|
|
27
|
+
"""An observation produced by an intelligence channel."""
|
|
28
|
+
|
|
29
|
+
id: str
|
|
30
|
+
room_id: str
|
|
31
|
+
channel_id: str
|
|
32
|
+
content: str
|
|
33
|
+
category: str | None = None
|
|
34
|
+
confidence: float = Field(default=1.0, ge=0.0, le=1.0)
|
|
35
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
36
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Abstract base class for AI providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from roomkit.models.channel import ChannelCapabilities
|
|
11
|
+
from roomkit.models.context import RoomContext
|
|
12
|
+
from roomkit.models.enums import ChannelMediaType
|
|
13
|
+
from roomkit.models.task import Observation, Task
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AITextPart(BaseModel):
|
|
17
|
+
"""Text part of a multimodal message."""
|
|
18
|
+
|
|
19
|
+
type: Literal["text"] = "text"
|
|
20
|
+
text: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AIImagePart(BaseModel):
|
|
24
|
+
"""Image part of a multimodal message."""
|
|
25
|
+
|
|
26
|
+
type: Literal["image"] = "image"
|
|
27
|
+
url: str
|
|
28
|
+
mime_type: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AITool(BaseModel):
|
|
32
|
+
"""Tool definition for function calling."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
description: str
|
|
36
|
+
parameters: dict[str, Any] = Field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AIToolCall(BaseModel):
|
|
40
|
+
"""A tool call from the AI response."""
|
|
41
|
+
|
|
42
|
+
id: str
|
|
43
|
+
name: str
|
|
44
|
+
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ProviderError(Exception):
|
|
48
|
+
"""Error from an AI provider SDK call.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
retryable: Whether the caller should retry the request.
|
|
52
|
+
provider: Name of the provider that raised the error.
|
|
53
|
+
status_code: HTTP status code from the provider, if available.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
message: str,
|
|
59
|
+
*,
|
|
60
|
+
retryable: bool = False,
|
|
61
|
+
provider: str = "",
|
|
62
|
+
status_code: int | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
super().__init__(message)
|
|
65
|
+
self.retryable = retryable
|
|
66
|
+
self.provider = provider
|
|
67
|
+
self.status_code = status_code
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AIMessage(BaseModel):
|
|
71
|
+
"""A message in the AI conversation context."""
|
|
72
|
+
|
|
73
|
+
role: str # "system", "user", "assistant"
|
|
74
|
+
content: str | list[AITextPart | AIImagePart]
|
|
75
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AIContext(BaseModel):
|
|
79
|
+
"""Context passed to AI provider for generation."""
|
|
80
|
+
|
|
81
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
82
|
+
|
|
83
|
+
messages: list[AIMessage] = Field(default_factory=list)
|
|
84
|
+
system_prompt: str | None = None
|
|
85
|
+
temperature: float = 0.7
|
|
86
|
+
max_tokens: int = 1024
|
|
87
|
+
tools: list[AITool] = Field(default_factory=list)
|
|
88
|
+
room: RoomContext | None = None
|
|
89
|
+
target_capabilities: ChannelCapabilities | None = None
|
|
90
|
+
target_media_types: list[ChannelMediaType] = Field(default_factory=list)
|
|
91
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AIResponse(BaseModel):
|
|
95
|
+
"""Response from an AI provider."""
|
|
96
|
+
|
|
97
|
+
content: str
|
|
98
|
+
finish_reason: str | None = None
|
|
99
|
+
usage: dict[str, int] = Field(default_factory=dict)
|
|
100
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
101
|
+
tasks: list[Task] = Field(default_factory=list)
|
|
102
|
+
observations: list[Observation] = Field(default_factory=list)
|
|
103
|
+
tool_calls: list[AIToolCall] = Field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AIProvider(ABC):
|
|
107
|
+
"""AI model provider for generating responses."""
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def name(self) -> str:
|
|
111
|
+
"""Provider name (e.g. 'anthropic', 'openai')."""
|
|
112
|
+
return self.__class__.__name__
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def supports_vision(self) -> bool:
|
|
116
|
+
"""Whether this provider can process images."""
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
@abstractmethod
|
|
121
|
+
def model_name(self) -> str:
|
|
122
|
+
"""Model identifier (e.g. 'claude-sonnet-4-20250514', 'gpt-4o')."""
|
|
123
|
+
...
|
|
124
|
+
|
|
125
|
+
@abstractmethod
|
|
126
|
+
async def generate(self, context: AIContext) -> AIResponse:
|
|
127
|
+
"""Generate an AI response from the given context.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
context: Conversation context including messages, system prompt,
|
|
131
|
+
temperature, and target channel capabilities.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
The AI response with content, usage stats, and optional
|
|
135
|
+
tasks/observations.
|
|
136
|
+
"""
|
|
137
|
+
...
|
|
138
|
+
|
|
139
|
+
async def close(self) -> None: # noqa: B027
|
|
140
|
+
"""Release resources. Override in subclasses that hold connections."""
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Mock AI provider for testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from roomkit.providers.ai.base import AIContext, AIProvider, AIResponse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MockAIProvider(AIProvider):
|
|
9
|
+
"""Round-robin response provider for tests."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, responses: list[str] | None = None, *, vision: bool = False) -> None:
|
|
12
|
+
self.responses = responses or ["Hello from AI"]
|
|
13
|
+
self.calls: list[AIContext] = []
|
|
14
|
+
self._index = 0
|
|
15
|
+
self._vision = vision
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def model_name(self) -> str:
|
|
19
|
+
return "mock"
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def supports_vision(self) -> bool:
|
|
23
|
+
return self._vision
|
|
24
|
+
|
|
25
|
+
async def generate(self, context: AIContext) -> AIResponse:
|
|
26
|
+
self.calls.append(context)
|
|
27
|
+
content = self.responses[self._index % len(self.responses)]
|
|
28
|
+
self._index += 1
|
|
29
|
+
return AIResponse(
|
|
30
|
+
content=content,
|
|
31
|
+
finish_reason="stop",
|
|
32
|
+
usage={"prompt_tokens": 10, "completion_tokens": 5},
|
|
33
|
+
)
|