Skip to content
Merged
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
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ services:
SP_FEATURE_CHAT_RUNTIME_RESULTS: ${SP_FEATURE_CHAT_RUNTIME_RESULTS:-true}
SP_FEATURE_CHAT_RUNTIME_ARTIFACTS: ${SP_FEATURE_CHAT_RUNTIME_ARTIFACTS:-true}
SP_FEATURE_CHAT_DATASET_REFS: ${SP_FEATURE_CHAT_DATASET_REFS:-false}
SP_FEATURE_CHAT_ORG_SHARING: ${SP_FEATURE_CHAT_ORG_SHARING:-false}
SP_FEATURE_CHAT_FORKING: ${SP_FEATURE_CHAT_FORKING:-false}
# Dedicated billing key for automated improvement runs (from root .env).
SP_IMPROVEMENT_ANTHROPIC_KEY: ${SP_IMPROVEMENT_ANTHROPIC_KEY:-}
SP_CHAT_OBJECTS_BUCKET: ${SP_CHAT_OBJECTS_BUCKET:-sp-chat-runtime}
Expand Down Expand Up @@ -228,6 +230,8 @@ services:
SP_FEATURE_CHAT_RUNTIME_RESULTS: ${SP_FEATURE_CHAT_RUNTIME_RESULTS:-true}
SP_FEATURE_CHAT_RUNTIME_ARTIFACTS: ${SP_FEATURE_CHAT_RUNTIME_ARTIFACTS:-true}
SP_FEATURE_CHAT_DATASET_REFS: ${SP_FEATURE_CHAT_DATASET_REFS:-false}
SP_FEATURE_CHAT_ORG_SHARING: ${SP_FEATURE_CHAT_ORG_SHARING:-false}
SP_FEATURE_CHAT_FORKING: ${SP_FEATURE_CHAT_FORKING:-false}
# Dedicated billing key for automated improvement runs (from root .env).
SP_IMPROVEMENT_ANTHROPIC_KEY: ${SP_IMPROVEMENT_ANTHROPIC_KEY:-}
SP_CHAT_OBJECTS_BUCKET: ${SP_CHAT_OBJECTS_BUCKET:-sp-chat-runtime}
Expand Down
26 changes: 2 additions & 24 deletions signalpilot/gateway/gateway/api/chat_routes/conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@
from gateway.git.repos import branch_head_sha
from gateway.models.standalone_chat import (
ChatShareGrantInfo,
ForkConfirmation,
ForkedConversationInfo,
ForkPreviewInfo,
SharedConversationDetail,
StandaloneConversationCreate,
StandaloneConversationDetail,
Expand Down Expand Up @@ -334,7 +332,8 @@ async def get_shared_conversation(
response_model=ForkedConversationInfo,
dependencies=[RequireScope("write")],
)
async def fork_shared_conversation(token: str, body: ForkConfirmation, store: StoreD):
async def fork_shared_conversation(token: str, store: StoreD):
"""Copy the whole shared chat into the caller's chats. No body needed."""
_require_enabled()
_require_enterprise_feature("forking")
try:
Expand All @@ -343,30 +342,9 @@ async def fork_shared_conversation(token: str, body: ForkConfirmation, store: St
org_id=store._require_org_id(),
user_id=store.user_id or "local",
token=token,
per_query_budget_usd=body.per_query_budget_usd,
chat_budget_usd=body.chat_budget_usd,
)
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if conversation is None:
raise HTTPException(status_code=404, detail="Shared conversation not found")
return ForkedConversationInfo(id=conversation.id)


@router.get(
"/shared/{token}/fork-preview",
response_model=ForkPreviewInfo,
dependencies=[RequireScope("read")],
)
async def preview_shared_conversation_fork(token: str, store: StoreD):
_require_enabled()
_require_enterprise_feature("forking")
preview = await chat_store.get_fork_preview(
store.session,
org_id=store._require_org_id(),
user_id=store.user_id or "local",
token=token,
)
if preview is None:
raise HTTPException(status_code=404, detail="Shared conversation not found")
return preview
34 changes: 19 additions & 15 deletions signalpilot/gateway/gateway/api/chat_routes/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from gateway.standalone_chat.object_storage import chat_object_storage
from gateway.standalone_chat.sql_trace import list_sql_trace
from gateway.store import standalone_chat as chat_store
from gateway.store.standalone_chat.files import file_manifest_entry

from ..deps import StoreD
from .common import owned_conversation_or_404 as _owned_conversation_or_404
Expand All @@ -40,21 +41,9 @@
IfNoneMatchD = Annotated[str | None, Header(alias="If-None-Match")]


def _file_info(row: GatewayChatFile) -> dict:
return {
"id": row.id,
"path": row.path,
"filename": row.filename,
"kind": row.kind,
"mime_type": row.mime_type,
"byte_size": row.byte_size,
"content_hash": row.content_hash,
"origin_run_id": row.origin_run_id,
"origin": row.origin,
"status": row.status,
"created_at": row.created_at,
"updated_at": row.updated_at,
}
# The manifest wire shape lives in the store so the shared snapshot can
# embed it without importing this module.
_file_info = file_manifest_entry


def _etag(row: GatewayChatFile) -> str:
Expand Down Expand Up @@ -219,3 +208,18 @@ async def get_conversation_sql_trace(conversation_id: str, store: StoreD):
conversation_id=conversation_id,
)
return {"executions": executions}


@router.get("/shared/{token}/sql-trace", dependencies=[RequireScope("read")])
async def get_shared_conversation_sql_trace(token: str, store: StoreD):
"""Return the shared chat's governed query executions for finished runs."""
_require_enabled()
_require_enterprise_feature("organization_sharing")
executions = await chat_store.list_shared_sql_trace(
store.session,
org_id=store._require_org_id(),
token=token,
)
if executions is None:
raise HTTPException(status_code=404, detail="Shared conversation not found")
return {"executions": executions}
16 changes: 6 additions & 10 deletions signalpilot/gateway/gateway/api/chat_routes/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
evaluate_project_readiness,
resolve_default_project,
)
from gateway.store.standalone_chat.preferences import default_chat_budgets

from ..deps import StoreD
from .common import is_admin as _is_admin
Expand Down Expand Up @@ -121,14 +122,9 @@ async def bootstrap_chat(store: StoreD, role: OrgRole):
project=selected,
readiness=readiness_by_project[selected_id],
)
preference = (
await store.session.execute(
select(GatewayChatUserPreference).where(
GatewayChatUserPreference.org_id == org_id,
GatewayChatUserPreference.user_id == user_id,
)
)
).scalar_one_or_none()
per_query_budget_usd, chat_budget_usd = await default_chat_budgets(
store.session, org_id=org_id, user_id=user_id
)
return ChatBootstrapResponse(
enabled=True,
projects=[
Expand All @@ -155,8 +151,8 @@ async def bootstrap_chat(store: StoreD, role: OrgRole):
selected_project_id=selected_id,
is_admin=_is_admin(role),
starter_questions=starters,
default_per_query_budget_usd=(preference.default_per_query_budget_usd if preference else 0.25),
default_chat_budget_usd=(preference.default_chat_budget_usd if preference else 1.0),
default_per_query_budget_usd=per_query_budget_usd,
default_chat_budget_usd=chat_budget_usd,
available_models=model_options,
default_model=selected_model,
available_efforts=effort_options,
Expand Down
76 changes: 55 additions & 21 deletions signalpilot/gateway/gateway/api/chat_routes/query_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@
from gateway.db.models import GatewayStructuredQueryResult
from gateway.security.scope_guard import RequireScope
from gateway.standalone_chat.query_results import QueryResultUnavailable, load_result_rows
from gateway.store import standalone_chat as chat_store

from ..deps import StoreD
from .common import owned_conversation_or_404, require_enabled
from .common import owned_conversation_or_404, require_enabled, require_enterprise_feature

router = APIRouter()

Expand All @@ -37,6 +38,31 @@ def _connection_name(provenance: object) -> str | None:
return value if isinstance(value, str) and value else None


def _clamp(offset: int, limit: int) -> tuple[int, int]:
"""Clamp instead of rejecting: offset >= 0, 1 <= limit <= MAX_LIMIT."""
return max(0, offset), min(max(1, limit), MAX_LIMIT)


async def _result_page(stored: GatewayStructuredQueryResult, *, offset: int, limit: int) -> dict:
try:
rows = await load_result_rows(stored)
except QueryResultUnavailable as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
"result_id": stored.id,
"execution_id": stored.execution_id,
"columns": stored.columns_json,
"rows": rows[offset : offset + limit],
"offset": offset,
"limit": limit,
"saved_row_count": stored.saved_row_count,
"query_row_count": stored.query_row_count,
"completeness": stored.result_completeness,
"truncation_reason": stored.truncation_reason,
"connection_name": _connection_name(stored.provenance_json),
}


@router.get(
"/conversations/{conversation_id}/results/{result_id}",
dependencies=[RequireScope("read")],
Expand All @@ -51,9 +77,7 @@ async def get_conversation_query_result(
"""Return one page of saved rows for a result the owner produced in this conversation."""
require_enabled()
await owned_conversation_or_404(store, conversation_id)
# Clamp instead of rejecting: offset >= 0, 1 <= limit <= MAX_LIMIT.
offset = max(0, offset)
limit = min(max(1, limit), MAX_LIMIT)
offset, limit = _clamp(offset, limit)
stored = (
await store.session.execute(
select(GatewayStructuredQueryResult).where(
Expand All @@ -66,20 +90,30 @@ async def get_conversation_query_result(
).scalar_one_or_none()
if stored is None:
raise HTTPException(status_code=404, detail="Query result not found")
try:
rows = await load_result_rows(stored)
except QueryResultUnavailable as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
"result_id": stored.id,
"execution_id": stored.execution_id,
"columns": stored.columns_json,
"rows": rows[offset : offset + limit],
"offset": offset,
"limit": limit,
"saved_row_count": stored.saved_row_count,
"query_row_count": stored.query_row_count,
"completeness": stored.result_completeness,
"truncation_reason": stored.truncation_reason,
"connection_name": _connection_name(stored.provenance_json),
}
return await _result_page(stored, offset=offset, limit=limit)


@router.get(
"/shared/{token}/results/{result_id}",
dependencies=[RequireScope("read")],
)
async def get_shared_query_result(
token: str,
result_id: str,
store: StoreD,
offset: int = 0,
limit: int = DEFAULT_LIMIT,
):
"""Same page shape for a shared chat, scoped to the grant's owner and conversation."""
require_enabled()
require_enterprise_feature("organization_sharing")
offset, limit = _clamp(offset, limit)
stored = await chat_store.get_shared_query_result(
store.session,
org_id=store._require_org_id(),
token=token,
result_id=result_id,
)
if stored is None:
raise HTTPException(status_code=404, detail="Query result not found")
return await _result_page(stored, offset=offset, limit=limit)
41 changes: 16 additions & 25 deletions signalpilot/gateway/gateway/models/standalone_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,42 +263,33 @@ class ChatShareGrantInfo(BaseModel):


class SharedConversationInfo(BaseModel):
"""Share-safe conversation header. No owner ids, no budgets, no spend."""

title: str
project_name: str | None = None
origin: str = "user"
model: str
effort: str = "medium"
commit_sha: str | None = None
branch: str
created_at: float
updated_at: float


class SharedMessageInfo(BaseModel):
id: str
role: Literal["user", "assistant"]
content: str
sequence: int
created_at: float
class SharedConversationDetail(BaseModel):
"""Read-only snapshot of a shared chat: finished runs only.

Messages and events carry the same shapes the owner sees so the shared
page renders through the same components. Files are the share-safe
manifest in the same dict shape as the owner file routes.
"""

class SharedConversationDetail(BaseModel):
conversation: SharedConversationInfo
messages: list[SharedMessageInfo]
messages: list[StandaloneMessageInfo]
run_events: list[ChatRunEventInfo] = Field(default_factory=list)
files: list[dict[str, Any]] = Field(default_factory=list)
shared_at: datetime


class ForkedConversationInfo(BaseModel):
id: str


class ForkPreviewInfo(BaseModel):
project_id: str
project_name: str
commit_sha: str
per_query_budget_usd: float
chat_budget_usd: float
warehouse_cost_notice: str


class ForkConfirmation(BaseModel):
model_config = ConfigDict(extra="forbid")

confirmed: Literal[True]
per_query_budget_usd: float = Field(ge=0)
chat_budget_usd: float = Field(ge=0)
Loading
Loading