Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 62 additions & 8 deletions app/api/routers/breeze_buddy/chat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from app.schemas import UserInfo
from app.schemas.breeze_buddy.chat import (
ApproveToolRequest,
ChatSession,
ChatSessionStatus,
ChatTranscriptResponse,
CreateChatSessionRequest,
Expand All @@ -47,6 +48,10 @@
ListChatSessionsResponse,
SendChatMessageRequest,
)
from app.services.breeze_buddy.copilot.scope import (
CopilotScopeError,
validate_persisted_copilot_scope_access,
)

from .demo import router as demo_router
from .handlers import (
Expand All @@ -66,6 +71,31 @@
router = APIRouter(prefix="/chat", tags=["chat"])


def _hidden_scope_error(error: CopilotScopeError) -> HTTPException:
if error.status_code == status.HTTP_404_NOT_FOUND:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Chat session not found",
)
return HTTPException(
status_code=error.status_code,
detail={"code": error.code, "message": error.message},
)


async def _validate_chat_and_copilot_session_access(
current_user: UserInfo,
session: ChatSession,
*,
operation: str,
) -> None:
validate_chat_session_access(current_user, session, operation=operation)
try:
await validate_persisted_copilot_scope_access(session.metadata, current_user)
except CopilotScopeError as error:
raise _hidden_scope_error(error) from error


@router.post(
"/session",
status_code=status.HTTP_201_CREATED,
Expand Down Expand Up @@ -184,7 +214,11 @@ async def get_session(
(returns 404 to avoid leaking existence)
"""
session = await load_chat_session_or_404(session_id)
validate_chat_session_access(current_user, session, operation="get_session")
await _validate_chat_and_copilot_session_access(
current_user,
session,
operation="get_session",
)
return await get_chat_session_handler(session)


Expand Down Expand Up @@ -216,8 +250,12 @@ async def send_message(
# (the demo router passes ``access_check=None`` — the demo token is
# already bound to a specific session_id and there's nothing further
# to authorise).
def _check(session) -> None:
validate_chat_session_access(current_user, session, operation="send_message")
async def _check(session) -> None:
await _validate_chat_and_copilot_session_access(
current_user,
session,
operation="send_message",
)

return await send_chat_message_handler(session_id, req, access_check=_check)

Expand Down Expand Up @@ -247,8 +285,12 @@ async def approve_tool(
``lock_contended``) on conflicts.
"""

def _check(session) -> None:
validate_chat_session_access(current_user, session, operation="approve_tool")
async def _check(session) -> None:
await _validate_chat_and_copilot_session_access(
current_user,
session,
operation="approve_tool",
)

return await approve_chat_tool_handler(session_id, req, access_check=_check)

Expand Down Expand Up @@ -281,7 +323,11 @@ async def cancel_turn(
we're cancelling.
"""
session = await load_chat_session_or_404(session_id)
validate_chat_session_access(current_user, session, operation="cancel_turn")
await _validate_chat_and_copilot_session_access(
current_user,
session,
operation="cancel_turn",
)
await cancel_chat_turn_handler(session_id)
return Response(status_code=status.HTTP_202_ACCEPTED)

Expand All @@ -302,7 +348,11 @@ async def end_session(
- Reseller / Merchant: Must own the session (404 otherwise)
"""
session = await load_chat_session_or_404(session_id)
validate_chat_session_access(current_user, session, operation="end_session")
await _validate_chat_and_copilot_session_access(
current_user,
session,
operation="end_session",
)
return await end_chat_session_handler(session_id, session)


Expand All @@ -322,7 +372,11 @@ async def get_transcript(
- Reseller / Merchant: Must own the session (404 otherwise)
"""
session = await load_chat_session_or_404(session_id)
validate_chat_session_access(current_user, session, operation="get_transcript")
await _validate_chat_and_copilot_session_access(
current_user,
session,
operation="get_transcript",
)
return await get_chat_transcript_handler(session)


Expand Down
90 changes: 73 additions & 17 deletions app/api/routers/breeze_buddy/chat/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
import asyncio
import time
from datetime import datetime
from typing import Any, AsyncIterator, Callable, Dict, List, Optional
from inspect import isawaitable
from typing import Any, AsyncIterator, Awaitable, Callable, Dict, List, Optional

from fastapi import HTTPException, status
from fastapi.responses import StreamingResponse
Expand Down Expand Up @@ -86,6 +87,11 @@
ToolApprovalStatus,
)
from app.schemas.breeze_buddy.conversation_analysis import ConversationChannel
from app.schemas.breeze_buddy.copilot import COPILOT_SCOPE_METADATA_KEY
from app.services.breeze_buddy.copilot.scope import (
CopilotScopeError,
resolve_copilot_scope,
)
from app.services.redis.locks import LockAcquireError, RedisLock

from . import cancel_bus
Expand All @@ -94,13 +100,59 @@
# is well under this — if it isn't, the upstream LLM is hung and we
# want the lock to expire so retries can recover. No mid-turn renewal.
_SESSION_LOCK_TTL_SECONDS = 180
_RESERVED_METADATA_KEYS = frozenset({"template_vars", COPILOT_SCOPE_METADATA_KEY})
AccessCheck = Callable[[ChatSession], Awaitable[None] | None]


def _lock_key(session_id: str) -> str:
"""Redis key for the per-session distributed lock."""
return f"chat:session:{session_id}:lock"


def _scope_http_error(error: CopilotScopeError) -> HTTPException:
return HTTPException(
status_code=error.status_code,
detail={"code": error.code, "message": error.message},
)


async def _run_access_check(
access_check: Optional[AccessCheck],
session: ChatSession,
) -> None:
if access_check is None:
return
result = access_check(session)
if isawaitable(result):
await result


def _validate_client_metadata(metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
client_metadata = dict(metadata or {})
reserved_keys = _RESERVED_METADATA_KEYS.intersection(client_metadata)
if reserved_keys:
key = sorted(reserved_keys)[0]
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"metadata.{key} is server-owned",
)
return client_metadata


def _build_session_metadata(
*,
client_metadata: Dict[str, Any],
template_vars: Dict[str, Any],
server_metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Merge session metadata while protecting server-owned namespaces."""
return {
**client_metadata,
**(server_metadata or {}),
"template_vars": template_vars,
}


async def _persist_turn_metrics(metrics: TurnMetrics) -> None:
"""Best-effort write of one turn's metrics to chat_turn_metrics.

Expand Down Expand Up @@ -330,13 +382,20 @@ async def create_chat_session_handler(
transformed_template_vars = _apply_payload_transformations(
req.template_vars, template.expected_payload_schema
)
# Server-owned `template_vars` must win over any client-supplied
# metadata: a crafted `metadata={"template_vars": ...}` would otherwise
# corrupt prompt rendering for every subsequent turn on this session.
persisted_metadata = {
**(req.metadata or {}),
"template_vars": transformed_template_vars,
}
client_metadata = _validate_client_metadata(req.metadata)
resolved_server_metadata: Dict[str, Any] = {}
if req.copilot_scope is not None:
try:
scope = await resolve_copilot_scope(req.copilot_scope, current_user)
except CopilotScopeError as error:
raise _scope_http_error(error) from error
resolved_server_metadata.update(scope.session_metadata())
Comment thread
charan-vadd marked this conversation as resolved.

persisted_metadata = _build_session_metadata(
client_metadata=client_metadata,
template_vars=transformed_template_vars,
server_metadata=resolved_server_metadata,
)
db_session = await create_chat_session(
template_id=req.template_id,
reseller_id=template.reseller_id,
Expand Down Expand Up @@ -445,7 +504,7 @@ async def send_chat_message_handler(
session_id: str,
req: SendChatMessageRequest,
*,
access_check: Optional[Callable[[ChatSession], None]] = None,
access_check: Optional[AccessCheck] = None,
internal: bool = False,
) -> StreamingResponse:
"""Drive one turn; stream SSE events until ``turn_end``.
Expand Down Expand Up @@ -504,8 +563,7 @@ async def send_chat_message_handler(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Chat session '{session_id}' not found",
)
if access_check is not None:
access_check(fresh)
await _run_access_check(access_check, fresh)
if fresh.status == ChatSessionStatus.ENDED:
raise HTTPException(
status_code=status.HTTP_410_GONE,
Expand Down Expand Up @@ -671,7 +729,7 @@ async def send_chat_intent_handler(
parsed: ParsedIntent,
*,
context: Any = None,
access_check: Optional[Callable[[ChatSession], None]] = None,
access_check: Optional[AccessCheck] = None,
) -> StreamingResponse:
"""Drive one DIRECT-routed UI intent (RFC-001 §3.3); stream SSE until
``turn_end``. The no-LLM sibling of ``send_chat_message_handler`` —
Expand Down Expand Up @@ -705,8 +763,7 @@ async def send_chat_intent_handler(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Chat session '{session_id}' not found",
)
if access_check is not None:
access_check(fresh)
await _run_access_check(access_check, fresh)
if fresh.status == ChatSessionStatus.ENDED:
raise HTTPException(
status_code=status.HTTP_410_GONE,
Expand Down Expand Up @@ -894,7 +951,7 @@ async def approve_chat_tool_handler(
session_id: str,
req: ApproveToolRequest,
*,
access_check: Optional[Callable[[ChatSession], None]] = None,
access_check: Optional[AccessCheck] = None,
) -> StreamingResponse:
"""Apply a HITL decision to a pending tool approval and stream the
resumed turn (same SSE shape as ``/message``).
Expand Down Expand Up @@ -932,8 +989,7 @@ async def approve_chat_tool_handler(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Chat session '{session_id}' not found",
)
if access_check is not None:
access_check(fresh)
await _run_access_check(access_check, fresh)
if fresh.status == ChatSessionStatus.ENDED:
raise HTTPException(
status_code=status.HTTP_410_GONE,
Expand Down
9 changes: 9 additions & 0 deletions app/schemas/breeze_buddy/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from pydantic import BaseModel, Field, model_validator

from app.ai.voice.agents.breeze_buddy.template.ui_catalog import ActionUnion, Icon
from app.schemas.breeze_buddy.copilot import CopilotScopeRequest


class ChatSessionStatus(str, Enum):
Expand Down Expand Up @@ -229,6 +230,14 @@ class CreateChatSessionRequest(BaseModel):
default_factory=dict,
description="Opaque caller-provided context, persisted on chat_session.metadata.",
)
copilot_scope: Optional[CopilotScopeRequest] = Field(
default=None,
description=(
"Optional dashboard data scope for Buddy Copilot-style Assist "
"templates. The server validates and persists the resolved scope "
"under metadata.copilot."
),
)


class GreetingMessage(BaseModel):
Expand Down
10 changes: 8 additions & 2 deletions app/schemas/breeze_buddy/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ class CopilotDateRangeSource(str, Enum):
class CopilotRequestedDateRange(BaseModel):
"""Optional dashboard-provided date range for Copilot data reads."""

model_config = ConfigDict(extra="forbid")

date_from: date
date_to: date

Expand All @@ -50,6 +52,8 @@ class CopilotScopeRequest(BaseModel):
remains authoritative and returns the immutable CopilotScope.
"""

model_config = ConfigDict(extra="forbid")

data_merchant_id: Optional[str] = Field(
default=None,
description="Selected merchant whose analytics/conversations are queried.",
Expand Down Expand Up @@ -141,8 +145,10 @@ class CopilotScope(BaseModel):
capabilities: tuple[CopilotCapability, ...]

def session_metadata(self) -> Dict[str, Dict[str, object]]:
"""Return semantic scope metadata for the normal Assist chat session."""
return {COPILOT_SCOPE_METADATA_KEY: self.model_dump(mode="json")}
"""Return redacted scope metadata for the normal Assist chat session."""
scope_payload = self.model_dump(mode="json", exclude={"actor"})
scope_payload["actor"] = {"user_id": self.actor.user_id}
return {COPILOT_SCOPE_METADATA_KEY: scope_payload}


class CopilotResolvedScopeResponse(BaseModel):
Expand Down
2 changes: 2 additions & 0 deletions app/services/breeze_buddy/copilot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
from app.services.breeze_buddy.copilot.scope import (
CopilotScopeError,
resolve_copilot_scope,
validate_persisted_copilot_scope_access,
)

__all__ = [
"CopilotScopeError",
"resolve_copilot_scope",
"validate_persisted_copilot_scope_access",
]
Loading
Loading