rossum-agent 1.0.0rc0__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.
- rossum_agent/__init__.py +9 -0
- rossum_agent/agent/__init__.py +32 -0
- rossum_agent/agent/core.py +932 -0
- rossum_agent/agent/memory.py +176 -0
- rossum_agent/agent/models.py +160 -0
- rossum_agent/agent/request_classifier.py +152 -0
- rossum_agent/agent/skills.py +132 -0
- rossum_agent/agent/types.py +5 -0
- rossum_agent/agent_logging.py +56 -0
- rossum_agent/api/__init__.py +1 -0
- rossum_agent/api/cli.py +51 -0
- rossum_agent/api/dependencies.py +190 -0
- rossum_agent/api/main.py +180 -0
- rossum_agent/api/models/__init__.py +1 -0
- rossum_agent/api/models/schemas.py +301 -0
- rossum_agent/api/routes/__init__.py +1 -0
- rossum_agent/api/routes/chats.py +95 -0
- rossum_agent/api/routes/files.py +113 -0
- rossum_agent/api/routes/health.py +44 -0
- rossum_agent/api/routes/messages.py +218 -0
- rossum_agent/api/services/__init__.py +1 -0
- rossum_agent/api/services/agent_service.py +451 -0
- rossum_agent/api/services/chat_service.py +197 -0
- rossum_agent/api/services/file_service.py +65 -0
- rossum_agent/assets/Primary_light_logo.png +0 -0
- rossum_agent/bedrock_client.py +64 -0
- rossum_agent/prompts/__init__.py +27 -0
- rossum_agent/prompts/base_prompt.py +80 -0
- rossum_agent/prompts/system_prompt.py +24 -0
- rossum_agent/py.typed +0 -0
- rossum_agent/redis_storage.py +482 -0
- rossum_agent/rossum_mcp_integration.py +123 -0
- rossum_agent/skills/hook-debugging.md +31 -0
- rossum_agent/skills/organization-setup.md +60 -0
- rossum_agent/skills/rossum-deployment.md +102 -0
- rossum_agent/skills/schema-patching.md +61 -0
- rossum_agent/skills/schema-pruning.md +23 -0
- rossum_agent/skills/ui-settings.md +45 -0
- rossum_agent/streamlit_app/__init__.py +1 -0
- rossum_agent/streamlit_app/app.py +646 -0
- rossum_agent/streamlit_app/beep_sound.py +36 -0
- rossum_agent/streamlit_app/cli.py +17 -0
- rossum_agent/streamlit_app/render_modules.py +123 -0
- rossum_agent/streamlit_app/response_formatting.py +305 -0
- rossum_agent/tools/__init__.py +214 -0
- rossum_agent/tools/core.py +173 -0
- rossum_agent/tools/deploy.py +404 -0
- rossum_agent/tools/dynamic_tools.py +365 -0
- rossum_agent/tools/file_tools.py +62 -0
- rossum_agent/tools/formula.py +187 -0
- rossum_agent/tools/skills.py +31 -0
- rossum_agent/tools/spawn_mcp.py +227 -0
- rossum_agent/tools/subagents/__init__.py +31 -0
- rossum_agent/tools/subagents/base.py +303 -0
- rossum_agent/tools/subagents/hook_debug.py +591 -0
- rossum_agent/tools/subagents/knowledge_base.py +305 -0
- rossum_agent/tools/subagents/mcp_helpers.py +47 -0
- rossum_agent/tools/subagents/schema_patching.py +471 -0
- rossum_agent/url_context.py +167 -0
- rossum_agent/user_detection.py +100 -0
- rossum_agent/utils.py +128 -0
- rossum_agent-1.0.0rc0.dist-info/METADATA +311 -0
- rossum_agent-1.0.0rc0.dist-info/RECORD +67 -0
- rossum_agent-1.0.0rc0.dist-info/WHEEL +5 -0
- rossum_agent-1.0.0rc0.dist-info/entry_points.txt +3 -0
- rossum_agent-1.0.0rc0.dist-info/licenses/LICENSE +21 -0
- rossum_agent-1.0.0rc0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Pydantic models for API requests and responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CreateChatRequest(BaseModel):
|
|
12
|
+
"""Request body for creating a new chat session."""
|
|
13
|
+
|
|
14
|
+
mcp_mode: Literal["read-only", "read-write"] = "read-only"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ChatResponse(BaseModel):
|
|
18
|
+
"""Response for chat creation."""
|
|
19
|
+
|
|
20
|
+
chat_id: str
|
|
21
|
+
created_at: datetime
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ChatSummary(BaseModel):
|
|
25
|
+
"""Summary of a chat session for list responses."""
|
|
26
|
+
|
|
27
|
+
chat_id: str
|
|
28
|
+
timestamp: int
|
|
29
|
+
message_count: int
|
|
30
|
+
first_message: str
|
|
31
|
+
preview: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ChatListResponse(BaseModel):
|
|
35
|
+
"""Response for listing chat sessions."""
|
|
36
|
+
|
|
37
|
+
chats: list[ChatSummary]
|
|
38
|
+
total: int
|
|
39
|
+
limit: int
|
|
40
|
+
offset: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ImageContent(BaseModel):
|
|
44
|
+
"""Image content in a message."""
|
|
45
|
+
|
|
46
|
+
type: Literal["image"] = "image"
|
|
47
|
+
media_type: Literal["image/jpeg", "image/png", "image/gif", "image/webp"]
|
|
48
|
+
data: str = Field(..., description="Base64-encoded image data")
|
|
49
|
+
|
|
50
|
+
@field_validator("data")
|
|
51
|
+
@classmethod
|
|
52
|
+
def validate_base64_size(cls, v: str) -> str:
|
|
53
|
+
max_size = 5 * 1024 * 1024 # 5 MB limit for base64 data
|
|
54
|
+
if len(v) > max_size * 4 // 3: # Base64 is ~4/3 larger than binary
|
|
55
|
+
msg = "Image data exceeds maximum size of 5 MB"
|
|
56
|
+
raise ValueError(msg)
|
|
57
|
+
return v
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DocumentContent(BaseModel):
|
|
61
|
+
"""Document content in a message."""
|
|
62
|
+
|
|
63
|
+
type: Literal["document"] = "document"
|
|
64
|
+
media_type: Literal["application/pdf"]
|
|
65
|
+
data: str = Field(..., description="Base64-encoded document data")
|
|
66
|
+
filename: str = Field(..., description="Original filename of the document")
|
|
67
|
+
|
|
68
|
+
@field_validator("data")
|
|
69
|
+
@classmethod
|
|
70
|
+
def validate_base64_size(cls, v: str) -> str:
|
|
71
|
+
max_size = 20 * 1024 * 1024 # 20 MB limit for base64 data
|
|
72
|
+
if len(v) > max_size * 4 // 3:
|
|
73
|
+
msg = "Document data exceeds maximum size of 20 MB"
|
|
74
|
+
raise ValueError(msg)
|
|
75
|
+
return v
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TextContent(BaseModel):
|
|
79
|
+
"""Text content in a message."""
|
|
80
|
+
|
|
81
|
+
type: Literal["text"] = "text"
|
|
82
|
+
text: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Message(BaseModel):
|
|
86
|
+
"""A single chat message."""
|
|
87
|
+
|
|
88
|
+
role: Literal["user", "assistant"]
|
|
89
|
+
content: str | list[TextContent | ImageContent]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class FileInfo(BaseModel):
|
|
93
|
+
"""Information about a file in a chat session."""
|
|
94
|
+
|
|
95
|
+
filename: str
|
|
96
|
+
size: int
|
|
97
|
+
timestamp: str
|
|
98
|
+
mime_type: str | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ChatDetail(BaseModel):
|
|
102
|
+
"""Detailed chat session information."""
|
|
103
|
+
|
|
104
|
+
chat_id: str
|
|
105
|
+
messages: list[Message]
|
|
106
|
+
created_at: datetime
|
|
107
|
+
files: list[FileInfo]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class DeleteResponse(BaseModel):
|
|
111
|
+
"""Response for delete operations."""
|
|
112
|
+
|
|
113
|
+
deleted: bool
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class MessageRequest(BaseModel):
|
|
117
|
+
"""Request body for sending a message.
|
|
118
|
+
|
|
119
|
+
Supports text-only messages or multimodal messages with images and documents.
|
|
120
|
+
For image messages, use the `images` field with base64-encoded image data.
|
|
121
|
+
For document messages, use the `documents` field with base64-encoded PDF data.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
content: str = Field(..., min_length=1, max_length=50000, description="Text content of the message")
|
|
125
|
+
images: list[ImageContent] | None = Field(
|
|
126
|
+
default=None,
|
|
127
|
+
max_length=5,
|
|
128
|
+
description="Optional list of images (max 5) to include with the message",
|
|
129
|
+
)
|
|
130
|
+
documents: list[DocumentContent] | None = Field(
|
|
131
|
+
default=None,
|
|
132
|
+
max_length=5,
|
|
133
|
+
description="Optional list of PDF documents (max 5) to include with the message",
|
|
134
|
+
)
|
|
135
|
+
rossum_url: str | None = Field(default=None, description="Optional Rossum app URL for context")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class StepEvent(BaseModel):
|
|
139
|
+
"""Event emitted during agent execution via SSE.
|
|
140
|
+
|
|
141
|
+
Extended thinking mode separates the model's internal reasoning from its final response:
|
|
142
|
+
- "thinking": Model's chain-of-thought reasoning (from thinking blocks)
|
|
143
|
+
- "intermediate": Model's response text before tool calls
|
|
144
|
+
- "final_answer": Final response when no more tool calls needed
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
type: Literal["thinking", "intermediate", "tool_start", "tool_result", "final_answer", "error"]
|
|
148
|
+
step_number: int
|
|
149
|
+
content: str | None = None
|
|
150
|
+
tool_name: str | None = None
|
|
151
|
+
tool_arguments: dict | None = None
|
|
152
|
+
tool_progress: tuple[int, int] | None = None
|
|
153
|
+
result: str | None = None
|
|
154
|
+
is_error: bool = False
|
|
155
|
+
is_streaming: bool = False
|
|
156
|
+
is_final: bool = False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class SubAgentProgressEvent(BaseModel):
|
|
160
|
+
"""Event emitted during sub-agent (e.g., debug_hook Opus) execution via SSE."""
|
|
161
|
+
|
|
162
|
+
type: Literal["sub_agent_progress"] = "sub_agent_progress"
|
|
163
|
+
tool_name: str
|
|
164
|
+
iteration: int
|
|
165
|
+
max_iterations: int
|
|
166
|
+
current_tool: str | None = None
|
|
167
|
+
tool_calls: list[str] = Field(default_factory=list)
|
|
168
|
+
status: Literal["thinking", "searching", "analyzing", "running_tool", "completed", "running"] = "running"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class SubAgentTextEvent(BaseModel):
|
|
172
|
+
"""Event emitted when sub-agent streams text output via SSE."""
|
|
173
|
+
|
|
174
|
+
type: Literal["sub_agent_text"] = "sub_agent_text"
|
|
175
|
+
tool_name: str
|
|
176
|
+
text: str
|
|
177
|
+
is_final: bool = False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TokenUsageBySource(BaseModel):
|
|
181
|
+
"""Token usage for a specific source (main agent or sub-agent)."""
|
|
182
|
+
|
|
183
|
+
input_tokens: int
|
|
184
|
+
output_tokens: int
|
|
185
|
+
total_tokens: int
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def from_counts(cls, input_tokens: int, output_tokens: int) -> TokenUsageBySource:
|
|
189
|
+
"""Create from input/output counts, computing total."""
|
|
190
|
+
return cls(input_tokens=input_tokens, output_tokens=output_tokens, total_tokens=input_tokens + output_tokens)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class SubAgentTokenUsageDetail(BaseModel):
|
|
194
|
+
"""Token usage breakdown for sub-agents."""
|
|
195
|
+
|
|
196
|
+
input_tokens: int
|
|
197
|
+
output_tokens: int
|
|
198
|
+
total_tokens: int
|
|
199
|
+
by_tool: dict[str, TokenUsageBySource]
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def from_counts(
|
|
203
|
+
cls, input_tokens: int, output_tokens: int, by_tool: dict[str, tuple[int, int]]
|
|
204
|
+
) -> SubAgentTokenUsageDetail:
|
|
205
|
+
"""Create from input/output counts, computing total."""
|
|
206
|
+
return cls(
|
|
207
|
+
input_tokens=input_tokens,
|
|
208
|
+
output_tokens=output_tokens,
|
|
209
|
+
total_tokens=input_tokens + output_tokens,
|
|
210
|
+
by_tool={name: TokenUsageBySource.from_counts(inp, out) for name, (inp, out) in by_tool.items()},
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class TokenUsageBreakdown(BaseModel):
|
|
215
|
+
"""Token usage breakdown by agent vs sub-agents."""
|
|
216
|
+
|
|
217
|
+
total: TokenUsageBySource
|
|
218
|
+
main_agent: TokenUsageBySource
|
|
219
|
+
sub_agents: SubAgentTokenUsageDetail
|
|
220
|
+
|
|
221
|
+
@classmethod
|
|
222
|
+
def from_raw_counts(
|
|
223
|
+
cls,
|
|
224
|
+
total_input: int,
|
|
225
|
+
total_output: int,
|
|
226
|
+
main_input: int,
|
|
227
|
+
main_output: int,
|
|
228
|
+
sub_input: int,
|
|
229
|
+
sub_output: int,
|
|
230
|
+
sub_by_tool: dict[str, tuple[int, int]],
|
|
231
|
+
) -> TokenUsageBreakdown:
|
|
232
|
+
"""Create breakdown from raw token counts."""
|
|
233
|
+
return cls(
|
|
234
|
+
total=TokenUsageBySource.from_counts(total_input, total_output),
|
|
235
|
+
main_agent=TokenUsageBySource.from_counts(main_input, main_output),
|
|
236
|
+
sub_agents=SubAgentTokenUsageDetail.from_counts(sub_input, sub_output, sub_by_tool),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def format_summary_lines(self) -> list[str]:
|
|
240
|
+
"""Format token usage as human-readable lines."""
|
|
241
|
+
lines = [
|
|
242
|
+
"",
|
|
243
|
+
"=" * 60,
|
|
244
|
+
"TOKEN USAGE SUMMARY",
|
|
245
|
+
"=" * 60,
|
|
246
|
+
f"{'Category':<25} {'Input':>12} {'Output':>12} {'Total':>12}",
|
|
247
|
+
"-" * 60,
|
|
248
|
+
f"{'Main Agent':<25} {self.main_agent.input_tokens:>12,} {self.main_agent.output_tokens:>12,} {self.main_agent.total_tokens:>12,}",
|
|
249
|
+
f"{'Sub-agents (total)':<25} {self.sub_agents.input_tokens:>12,} {self.sub_agents.output_tokens:>12,} {self.sub_agents.total_tokens:>12,}",
|
|
250
|
+
]
|
|
251
|
+
for tool_name, usage in self.sub_agents.by_tool.items():
|
|
252
|
+
lines.append(
|
|
253
|
+
f" └─ {tool_name:<21} {usage.input_tokens:>12,} {usage.output_tokens:>12,} {usage.total_tokens:>12,}"
|
|
254
|
+
)
|
|
255
|
+
lines.extend(
|
|
256
|
+
[
|
|
257
|
+
"-" * 60,
|
|
258
|
+
f"{'TOTAL':<25} {self.total.input_tokens:>12,} {self.total.output_tokens:>12,} {self.total.total_tokens:>12,}",
|
|
259
|
+
"=" * 60,
|
|
260
|
+
]
|
|
261
|
+
)
|
|
262
|
+
return lines
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class StreamDoneEvent(BaseModel):
|
|
266
|
+
"""Final event emitted when streaming completes."""
|
|
267
|
+
|
|
268
|
+
total_steps: int
|
|
269
|
+
input_tokens: int
|
|
270
|
+
output_tokens: int
|
|
271
|
+
token_usage_breakdown: TokenUsageBreakdown | None = None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class FileCreatedEvent(BaseModel):
|
|
275
|
+
"""Event emitted when a file is created and stored."""
|
|
276
|
+
|
|
277
|
+
type: Literal["file_created"] = "file_created"
|
|
278
|
+
filename: str
|
|
279
|
+
url: str
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class HealthResponse(BaseModel):
|
|
283
|
+
"""Response for health check endpoint."""
|
|
284
|
+
|
|
285
|
+
status: Literal["healthy", "unhealthy"]
|
|
286
|
+
redis_connected: bool
|
|
287
|
+
version: str
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class ErrorResponse(BaseModel):
|
|
291
|
+
"""Standard error response."""
|
|
292
|
+
|
|
293
|
+
detail: str
|
|
294
|
+
error_code: str | None = None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class FileListResponse(BaseModel):
|
|
298
|
+
"""Response for listing files in a chat session."""
|
|
299
|
+
|
|
300
|
+
files: list[FileInfo]
|
|
301
|
+
total: int
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API route handlers."""
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Chat session CRUD endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable # noqa: TC003 - Required at runtime for service getter type hints
|
|
6
|
+
from typing import Annotated # noqa: TC003 - Required at runtime for FastAPI dependency injection
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
9
|
+
from slowapi import Limiter
|
|
10
|
+
from slowapi.util import get_remote_address
|
|
11
|
+
|
|
12
|
+
from rossum_agent.api.dependencies import RossumCredentials, get_validated_credentials
|
|
13
|
+
from rossum_agent.api.models.schemas import (
|
|
14
|
+
ChatDetail,
|
|
15
|
+
ChatListResponse,
|
|
16
|
+
ChatResponse,
|
|
17
|
+
CreateChatRequest,
|
|
18
|
+
DeleteResponse,
|
|
19
|
+
)
|
|
20
|
+
from rossum_agent.api.services.chat_service import (
|
|
21
|
+
ChatService, # noqa: TC001 - Required at runtime for FastAPI Depends()
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
limiter = Limiter(key_func=get_remote_address)
|
|
25
|
+
|
|
26
|
+
router = APIRouter(prefix="/chats", tags=["chats"])
|
|
27
|
+
|
|
28
|
+
_get_chat_service: Callable[[], ChatService] | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def set_chat_service_getter(getter: Callable[[], ChatService]) -> None:
|
|
32
|
+
"""Set the chat service getter function."""
|
|
33
|
+
global _get_chat_service
|
|
34
|
+
_get_chat_service = getter
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_chat_service_dep() -> ChatService:
|
|
38
|
+
"""Dependency function for chat service."""
|
|
39
|
+
if _get_chat_service is None:
|
|
40
|
+
raise RuntimeError("Chat service getter not configured")
|
|
41
|
+
return _get_chat_service()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.post("", response_model=ChatResponse, status_code=status.HTTP_201_CREATED)
|
|
45
|
+
@limiter.limit("30/minute")
|
|
46
|
+
async def create_chat(
|
|
47
|
+
request: Request,
|
|
48
|
+
body: CreateChatRequest | None = None,
|
|
49
|
+
credentials: Annotated[RossumCredentials, Depends(get_validated_credentials)] = None, # type: ignore[assignment]
|
|
50
|
+
chat_service: Annotated[ChatService, Depends(get_chat_service_dep)] = None, # type: ignore[assignment]
|
|
51
|
+
) -> ChatResponse:
|
|
52
|
+
"""Create a new chat session."""
|
|
53
|
+
mcp_mode = body.mcp_mode if body else "read-only"
|
|
54
|
+
return chat_service.create_chat(user_id=credentials.user_id, mcp_mode=mcp_mode)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.get("", response_model=ChatListResponse)
|
|
58
|
+
async def list_chats(
|
|
59
|
+
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
|
60
|
+
offset: Annotated[int, Query(ge=0)] = 0,
|
|
61
|
+
credentials: Annotated[RossumCredentials, Depends(get_validated_credentials)] = None, # type: ignore[assignment]
|
|
62
|
+
chat_service: Annotated[ChatService, Depends(get_chat_service_dep)] = None, # type: ignore[assignment]
|
|
63
|
+
) -> ChatListResponse:
|
|
64
|
+
"""List chat sessions for the authenticated user."""
|
|
65
|
+
return chat_service.list_chats(user_id=credentials.user_id, limit=limit, offset=offset)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.get("/{chat_id}", response_model=ChatDetail)
|
|
69
|
+
async def get_chat(
|
|
70
|
+
chat_id: str,
|
|
71
|
+
credentials: Annotated[RossumCredentials, Depends(get_validated_credentials)] = None, # type: ignore[assignment]
|
|
72
|
+
chat_service: Annotated[ChatService, Depends(get_chat_service_dep)] = None, # type: ignore[assignment]
|
|
73
|
+
) -> ChatDetail:
|
|
74
|
+
"""Get detailed information about a chat session."""
|
|
75
|
+
chat = chat_service.get_chat(user_id=credentials.user_id, chat_id=chat_id)
|
|
76
|
+
|
|
77
|
+
if chat is None:
|
|
78
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Chat {chat_id} not found")
|
|
79
|
+
|
|
80
|
+
return chat
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.delete("/{chat_id}", response_model=DeleteResponse)
|
|
84
|
+
async def delete_chat(
|
|
85
|
+
chat_id: str,
|
|
86
|
+
credentials: Annotated[RossumCredentials, Depends(get_validated_credentials)] = None, # type: ignore[assignment]
|
|
87
|
+
chat_service: Annotated[ChatService, Depends(get_chat_service_dep)] = None, # type: ignore[assignment]
|
|
88
|
+
) -> DeleteResponse:
|
|
89
|
+
"""Delete a chat session."""
|
|
90
|
+
if not chat_service.chat_exists(credentials.user_id, chat_id):
|
|
91
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Chat {chat_id} not found")
|
|
92
|
+
|
|
93
|
+
deleted = chat_service.delete_chat(user_id=credentials.user_id, chat_id=chat_id)
|
|
94
|
+
|
|
95
|
+
return DeleteResponse(deleted=deleted)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""File management endpoints for chat sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable # noqa: TC003 - Required at runtime for service getter type hints
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated # noqa: TC003 - Required at runtime for FastAPI dependency injection
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
11
|
+
from fastapi.responses import Response
|
|
12
|
+
|
|
13
|
+
from rossum_agent.api.dependencies import RossumCredentials, get_validated_credentials
|
|
14
|
+
from rossum_agent.api.models.schemas import FileListResponse
|
|
15
|
+
from rossum_agent.api.services.chat_service import (
|
|
16
|
+
ChatService, # noqa: TC001 - Required at runtime for FastAPI Depends()
|
|
17
|
+
)
|
|
18
|
+
from rossum_agent.api.services.file_service import (
|
|
19
|
+
FileService, # noqa: TC001 - Required at runtime for FastAPI Depends()
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
router = APIRouter(prefix="/chats/{chat_id}/files", tags=["files"])
|
|
23
|
+
|
|
24
|
+
_get_chat_service: Callable[[], ChatService] | None = None
|
|
25
|
+
_get_file_service: Callable[[], FileService] | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def set_chat_service_getter(getter: Callable[[], ChatService]) -> None:
|
|
29
|
+
"""Set the chat service getter function."""
|
|
30
|
+
global _get_chat_service
|
|
31
|
+
_get_chat_service = getter
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def set_file_service_getter(getter: Callable[[], FileService]) -> None:
|
|
35
|
+
"""Set the file service getter function."""
|
|
36
|
+
global _get_file_service
|
|
37
|
+
_get_file_service = getter
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_chat_service_dep() -> ChatService:
|
|
41
|
+
"""Dependency function for chat service."""
|
|
42
|
+
if _get_chat_service is None:
|
|
43
|
+
raise RuntimeError("Chat service getter not configured")
|
|
44
|
+
return _get_chat_service()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_file_service_dep() -> FileService:
|
|
48
|
+
"""Dependency function for file service."""
|
|
49
|
+
if _get_file_service is None:
|
|
50
|
+
raise RuntimeError("File service getter not configured")
|
|
51
|
+
return _get_file_service()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@router.get("", response_model=FileListResponse)
|
|
55
|
+
async def list_files(
|
|
56
|
+
chat_id: str,
|
|
57
|
+
credentials: Annotated[RossumCredentials, Depends(get_validated_credentials)] = None, # type: ignore[assignment]
|
|
58
|
+
chat_service: Annotated[ChatService, Depends(get_chat_service_dep)] = None, # type: ignore[assignment]
|
|
59
|
+
file_service: Annotated[FileService, Depends(get_file_service_dep)] = None, # type: ignore[assignment]
|
|
60
|
+
) -> FileListResponse:
|
|
61
|
+
"""List all files for a chat session."""
|
|
62
|
+
if not chat_service.chat_exists(credentials.user_id, chat_id):
|
|
63
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Chat {chat_id} not found")
|
|
64
|
+
|
|
65
|
+
files = file_service.list_files(chat_id)
|
|
66
|
+
return FileListResponse(files=files, total=len(files))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _sanitize_filename(filename: str) -> str:
|
|
70
|
+
"""Sanitize filename to prevent path traversal and header injection.
|
|
71
|
+
|
|
72
|
+
- Normalizes Windows backslash separators to forward slashes
|
|
73
|
+
- Extracts only the base filename (no directory traversal)
|
|
74
|
+
- Removes control characters, newlines, quotes, and backslashes (header injection prevention)
|
|
75
|
+
- Rejects directory-only names like ".." or "."
|
|
76
|
+
- Limits length to prevent DoS
|
|
77
|
+
"""
|
|
78
|
+
normalized = filename.replace("\\", "/")
|
|
79
|
+
safe_name = Path(normalized).name
|
|
80
|
+
safe_name = re.sub(r'[\x00-\x1f\x7f"\\]', "", safe_name)
|
|
81
|
+
if safe_name in ("", ".", ".."):
|
|
82
|
+
return ""
|
|
83
|
+
return safe_name[:255]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.get("/{filename:path}")
|
|
87
|
+
async def download_file(
|
|
88
|
+
chat_id: str,
|
|
89
|
+
filename: str,
|
|
90
|
+
credentials: Annotated[RossumCredentials, Depends(get_validated_credentials)] = None, # type: ignore[assignment]
|
|
91
|
+
chat_service: Annotated[ChatService, Depends(get_chat_service_dep)] = None, # type: ignore[assignment]
|
|
92
|
+
file_service: Annotated[FileService, Depends(get_file_service_dep)] = None, # type: ignore[assignment]
|
|
93
|
+
) -> Response:
|
|
94
|
+
"""Download a file from a chat session."""
|
|
95
|
+
if not chat_service.chat_exists(credentials.user_id, chat_id):
|
|
96
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Chat {chat_id} not found")
|
|
97
|
+
|
|
98
|
+
safe_filename = _sanitize_filename(filename)
|
|
99
|
+
if not safe_filename:
|
|
100
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid filename")
|
|
101
|
+
|
|
102
|
+
result = file_service.get_file(chat_id, safe_filename)
|
|
103
|
+
if result is None:
|
|
104
|
+
raise HTTPException(
|
|
105
|
+
status_code=status.HTTP_404_NOT_FOUND, detail=f"File {safe_filename} not found in chat {chat_id}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
content, mime_type = result
|
|
109
|
+
return Response(
|
|
110
|
+
content=content,
|
|
111
|
+
media_type=mime_type,
|
|
112
|
+
headers={"Content-Disposition": f'attachment; filename="{safe_filename}"'},
|
|
113
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Health check endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable # noqa: TC003 - Required at runtime for service getter type hints
|
|
6
|
+
from typing import Annotated # noqa: TC003 - Required at runtime for FastAPI dependency injection
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends
|
|
9
|
+
|
|
10
|
+
from rossum_agent.api.models.schemas import HealthResponse
|
|
11
|
+
from rossum_agent.api.services.chat_service import (
|
|
12
|
+
ChatService, # noqa: TC001 - Required at runtime for FastAPI Depends()
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
router = APIRouter(tags=["health"])
|
|
16
|
+
|
|
17
|
+
VERSION = "0.2.0"
|
|
18
|
+
|
|
19
|
+
_get_chat_service: Callable[[], ChatService] | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def set_chat_service_getter(getter: Callable[[], ChatService]) -> None:
|
|
23
|
+
"""Set the chat service getter function."""
|
|
24
|
+
global _get_chat_service
|
|
25
|
+
_get_chat_service = getter
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_chat_service_dep() -> ChatService:
|
|
29
|
+
"""Dependency function for chat service."""
|
|
30
|
+
if _get_chat_service is None:
|
|
31
|
+
raise RuntimeError("Chat service getter not configured")
|
|
32
|
+
return _get_chat_service()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@router.get("/health", response_model=HealthResponse)
|
|
36
|
+
async def health_check(
|
|
37
|
+
chat_service: Annotated[ChatService, Depends(get_chat_service_dep)],
|
|
38
|
+
) -> HealthResponse:
|
|
39
|
+
"""Check API health and dependencies."""
|
|
40
|
+
redis_connected = chat_service.is_connected()
|
|
41
|
+
|
|
42
|
+
return HealthResponse(
|
|
43
|
+
status="healthy" if redis_connected else "unhealthy", redis_connected=redis_connected, version=VERSION
|
|
44
|
+
)
|