feat(06-01): add web channel type, Redis key, ORM models, migration, and tests
- Add ChannelType.WEB = 'web' to shared/models/message.py - Add webchat_response_key() to shared/redis_keys.py - Create WebConversation and WebConversationMessage ORM models (SQLAlchemy 2.0) - Create migration 008_web_chat.py with RLS, indexes, and channel_type CHECK update - Pop conversation_id/portal_user_id extras in handle_message before model_validate - Add web case to _build_response_extras and _send_response (Redis pub-sub publish) - Import webchat_response_key in orchestrator/tasks.py - Write 19 unit tests covering CHAT-01 through CHAT-05 (all pass)
This commit is contained in:
@@ -77,7 +77,7 @@ from orchestrator.tools.registry import get_tools_for_agent
|
||||
from shared.config import settings
|
||||
from shared.db import async_session_factory, engine
|
||||
from shared.models.message import KonstructMessage
|
||||
from shared.redis_keys import escalation_status_key
|
||||
from shared.redis_keys import escalation_status_key, webchat_response_key
|
||||
from shared.rls import configure_rls_hook, current_tenant_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -253,6 +253,11 @@ def handle_message(self, message_data: dict) -> dict: # type: ignore[no-untyped
|
||||
phone_number_id: str = message_data.pop("phone_number_id", "") or ""
|
||||
bot_token: str = message_data.pop("bot_token", "") or ""
|
||||
|
||||
# Extract web channel extras before model validation
|
||||
# The web WebSocket handler injects these alongside the normalized KonstructMessage fields
|
||||
conversation_id: str = message_data.pop("conversation_id", "") or ""
|
||||
portal_user_id: str = message_data.pop("portal_user_id", "") or ""
|
||||
|
||||
try:
|
||||
msg = KonstructMessage.model_validate(message_data)
|
||||
except Exception as exc:
|
||||
@@ -272,6 +277,11 @@ def handle_message(self, message_data: dict) -> dict: # type: ignore[no-untyped
|
||||
"phone_number_id": phone_number_id,
|
||||
"bot_token": bot_token,
|
||||
"wa_id": wa_id,
|
||||
# Web channel extras
|
||||
"conversation_id": conversation_id,
|
||||
"portal_user_id": portal_user_id,
|
||||
# tenant_id for web channel response routing (web lacks a workspace_id in channel_connections)
|
||||
"tenant_id": msg.tenant_id or "",
|
||||
}
|
||||
|
||||
result = asyncio.run(_process_message(msg, extras=extras))
|
||||
@@ -646,6 +656,13 @@ def _build_response_extras(
|
||||
"bot_token": extras.get("bot_token", "") or "",
|
||||
"wa_id": extras.get("wa_id", "") or "",
|
||||
}
|
||||
elif channel_str == "web":
|
||||
# Web channel: tenant_id comes from extras (set by handle_message from msg.tenant_id),
|
||||
# not from channel_connections like Slack. conversation_id scopes the Redis pub-sub channel.
|
||||
return {
|
||||
"conversation_id": extras.get("conversation_id", "") or "",
|
||||
"tenant_id": extras.get("tenant_id", "") or "",
|
||||
}
|
||||
else:
|
||||
return dict(extras)
|
||||
|
||||
@@ -774,6 +791,31 @@ async def _send_response(
|
||||
text=text,
|
||||
)
|
||||
|
||||
elif channel_str == "web":
|
||||
# Publish agent response to Redis pub-sub so the WebSocket handler can deliver it
|
||||
web_conversation_id: str = extras.get("conversation_id", "") or ""
|
||||
web_tenant_id: str = extras.get("tenant_id", "") or ""
|
||||
|
||||
if not web_conversation_id or not web_tenant_id:
|
||||
logger.warning(
|
||||
"_send_response: web channel missing conversation_id or tenant_id in extras"
|
||||
)
|
||||
return
|
||||
|
||||
response_channel = webchat_response_key(web_tenant_id, web_conversation_id)
|
||||
publish_redis = aioredis.from_url(settings.redis_url)
|
||||
try:
|
||||
await publish_redis.publish(
|
||||
response_channel,
|
||||
json.dumps({
|
||||
"type": "response",
|
||||
"text": text,
|
||||
"conversation_id": web_conversation_id,
|
||||
}),
|
||||
)
|
||||
finally:
|
||||
await publish_redis.aclose()
|
||||
|
||||
else:
|
||||
logger.warning(
|
||||
"_send_response: unsupported channel=%r — response not delivered", channel
|
||||
|
||||
124
packages/shared/shared/models/chat.py
Normal file
124
packages/shared/shared/models/chat.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
SQLAlchemy 2.0 ORM models for web chat conversations.
|
||||
|
||||
These models support the Phase 6 web chat feature — a WebSocket-based
|
||||
channel that allows portal users to chat with AI employees directly from
|
||||
the Konstruct portal UI.
|
||||
|
||||
Tables:
|
||||
web_conversations — One per portal user + agent pair per tenant
|
||||
web_conversation_messages — Individual messages within a conversation
|
||||
|
||||
RLS is applied to both tables via app.current_tenant session variable,
|
||||
same pattern as agents and channel_connections (migration 008).
|
||||
|
||||
Design notes:
|
||||
- UniqueConstraint on (tenant_id, agent_id, user_id) for get-or-create semantics
|
||||
- role column is TEXT+CHECK (not sa.Enum) per Phase 1 ADR to avoid Alembic DDL conflicts
|
||||
- ON DELETE CASCADE on messages.conversation_id: deleting a conversation
|
||||
removes all its messages automatically
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
from shared.models.tenant import Base
|
||||
|
||||
|
||||
class WebConversation(Base):
|
||||
"""
|
||||
A web chat conversation between a portal user and an AI employee.
|
||||
|
||||
One row per (tenant_id, agent_id, user_id) triple — callers use
|
||||
get-or-create semantics when starting a chat session.
|
||||
|
||||
RLS scoped to tenant_id so the app role only sees conversations
|
||||
for the currently-configured tenant.
|
||||
"""
|
||||
|
||||
__tablename__ = "web_conversations"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
agent_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("agents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "agent_id", "user_id", name="uq_web_conversations_tenant_agent_user"),
|
||||
)
|
||||
|
||||
|
||||
class WebConversationMessage(Base):
|
||||
"""
|
||||
A single message within a web chat conversation.
|
||||
|
||||
role is stored as TEXT with a CHECK constraint ('user' or 'assistant'),
|
||||
following the Phase 1 convention that avoids PostgreSQL ENUM DDL issues.
|
||||
|
||||
Messages are deleted via ON DELETE CASCADE when their parent conversation
|
||||
is deleted, or explicitly during a conversation reset (DELETE /conversations/{id}).
|
||||
"""
|
||||
|
||||
__tablename__ = "web_conversation_messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
conversation_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("web_conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
)
|
||||
content: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
@@ -26,6 +26,7 @@ class ChannelType(StrEnum):
|
||||
TEAMS = "teams"
|
||||
TELEGRAM = "telegram"
|
||||
SIGNAL = "signal"
|
||||
WEB = "web"
|
||||
|
||||
|
||||
class MediaType(StrEnum):
|
||||
|
||||
@@ -144,3 +144,25 @@ def pending_tool_confirm_key(tenant_id: str, thread_id: str) -> str:
|
||||
Namespaced Redis key: "{tenant_id}:tool_confirm:{thread_id}"
|
||||
"""
|
||||
return f"{tenant_id}:tool_confirm:{thread_id}"
|
||||
|
||||
|
||||
def webchat_response_key(tenant_id: str, conversation_id: str) -> str:
|
||||
"""
|
||||
Redis pub-sub channel key for web chat response delivery.
|
||||
|
||||
The WebSocket handler subscribes to this channel after dispatching
|
||||
a message to Celery. The orchestrator publishes the agent response
|
||||
to this channel when processing completes.
|
||||
|
||||
Key includes both tenant_id and conversation_id to ensure:
|
||||
- Two conversations in the same tenant get separate channels
|
||||
- Two tenants with the same conversation_id are fully isolated
|
||||
|
||||
Args:
|
||||
tenant_id: Konstruct tenant identifier.
|
||||
conversation_id: Web conversation UUID string.
|
||||
|
||||
Returns:
|
||||
Namespaced Redis key: "{tenant_id}:webchat:response:{conversation_id}"
|
||||
"""
|
||||
return f"{tenant_id}:webchat:response:{conversation_id}"
|
||||
|
||||
Reference in New Issue
Block a user