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:
2026-03-25 10:26:34 -06:00
parent c0fa0cefee
commit c72beb916b
7 changed files with 957 additions and 1 deletions

View 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(),
)

View File

@@ -26,6 +26,7 @@ class ChannelType(StrEnum):
TEAMS = "teams"
TELEGRAM = "telegram"
SIGNAL = "signal"
WEB = "web"
class MediaType(StrEnum):

View File

@@ -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}"