deeptutor-server 1.7.0__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.
- deeptutor/api/__init__.py +0 -0
- deeptutor/api/contracts/__init__.py +21 -0
- deeptutor/api/contracts/export.py +110 -0
- deeptutor/api/contracts/turn_protocol.py +313 -0
- deeptutor/api/main.py +547 -0
- deeptutor/api/module_routes.py +42 -0
- deeptutor/api/router_index.json +5752 -0
- deeptutor/api/router_loader.py +139 -0
- deeptutor/api/routers/__init__.py +1 -0
- deeptutor/api/routers/agent_config.py +59 -0
- deeptutor/api/routers/attachments.py +79 -0
- deeptutor/api/routers/auth.py +1937 -0
- deeptutor/api/routers/capabilities.py +44 -0
- deeptutor/api/routers/capabilities_settings.py +38 -0
- deeptutor/api/routers/dashboard.py +292 -0
- deeptutor/api/routers/file_library.py +169 -0
- deeptutor/api/routers/file_preview.py +205 -0
- deeptutor/api/routers/imports.py +146 -0
- deeptutor/api/routers/learning_journal.py +18 -0
- deeptutor/api/routers/mcp_settings.py +133 -0
- deeptutor/api/routers/modules.py +12 -0
- deeptutor/api/routers/multi_user.py +943 -0
- deeptutor/api/routers/outputs.py +97 -0
- deeptutor/api/routers/personas.py +154 -0
- deeptutor/api/routers/plugins.py +416 -0
- deeptutor/api/routers/quiz_judge.py +464 -0
- deeptutor/api/routers/sessions.py +801 -0
- deeptutor/api/routers/settings.py +2503 -0
- deeptutor/api/routers/skills.py +315 -0
- deeptutor/api/routers/space_cli_apps.py +222 -0
- deeptutor/api/routers/space_mcp.py +460 -0
- deeptutor/api/routers/subagents.py +306 -0
- deeptutor/api/routers/system.py +633 -0
- deeptutor/api/routers/task_board.py +139 -0
- deeptutor/api/routers/tools.py +268 -0
- deeptutor/api/routers/unified_ws.py +385 -0
- deeptutor/api/routers/workspace.py +371 -0
- deeptutor/api/run_server.py +99 -0
- deeptutor/api/security_guard.py +91 -0
- deeptutor/api/utils/http_headers.py +27 -0
- deeptutor/api/utils/progress_broadcaster.py +73 -0
- deeptutor/api/utils/task_id_manager.py +120 -0
- deeptutor/api/utils/task_log_stream.py +362 -0
- deeptutor/api/utils/tool_options.py +133 -0
- deeptutor_server-1.7.0.dist-info/METADATA +14 -0
- deeptutor_server-1.7.0.dist-info/RECORD +48 -0
- deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_server-1.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Content-workspace settings and authenticated presentation delivery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
9
|
+
from fastapi.responses import FileResponse
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from deeptutor.api.routers.auth import require_auth
|
|
13
|
+
from deeptutor.multi_user.partner_access import visible_partners
|
|
14
|
+
from deeptutor.multi_user.paths import user_context
|
|
15
|
+
from deeptutor.services.auth import TokenPayload
|
|
16
|
+
from deeptutor.services.workspace import (
|
|
17
|
+
WorkspaceError,
|
|
18
|
+
WorkspaceItem,
|
|
19
|
+
get_content_workspace_service,
|
|
20
|
+
)
|
|
21
|
+
from deeptutor.services.workspace.models import MAX_WORKSPACE_INSTRUCTIONS_CHARS
|
|
22
|
+
from deeptutor.services.workspace.resources import WorkspaceResources
|
|
23
|
+
|
|
24
|
+
settings_router = APIRouter()
|
|
25
|
+
files_router = APIRouter()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WorkspacePathPayload(BaseModel):
|
|
29
|
+
path: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CreateWorkspacePayload(BaseModel):
|
|
33
|
+
instructions: str | None = Field(default=None, max_length=MAX_WORKSPACE_INSTRUCTIONS_CHARS)
|
|
34
|
+
resources: WorkspaceResources | None = None
|
|
35
|
+
name: str = Field(min_length=1, max_length=100)
|
|
36
|
+
path: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UpdateWorkspacePayload(BaseModel):
|
|
40
|
+
instructions: str | None = Field(default=None, max_length=MAX_WORKSPACE_INSTRUCTIONS_CHARS)
|
|
41
|
+
resources: WorkspaceResources | None = None
|
|
42
|
+
name: str | None = Field(default=None, min_length=1, max_length=100)
|
|
43
|
+
archived: bool | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DataMigrationPayload(BaseModel):
|
|
47
|
+
source_workspace_id: str = ""
|
|
48
|
+
target_workspace_id: str = ""
|
|
49
|
+
features: list[str] = Field(min_length=1)
|
|
50
|
+
include_historical: bool = False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class KnowledgeMovePayload(BaseModel):
|
|
54
|
+
source_id: str = Field(min_length=1)
|
|
55
|
+
target_workspace_id: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _data_operation(function, *args, **kwargs):
|
|
59
|
+
_assert_migration_access()
|
|
60
|
+
task = asyncio.create_task(asyncio.to_thread(function, *args, **kwargs))
|
|
61
|
+
task.add_done_callback(lambda done: done.exception() if not done.cancelled() else None)
|
|
62
|
+
try:
|
|
63
|
+
return await asyncio.shield(task)
|
|
64
|
+
except (WorkspaceError, OSError, ValueError) as exc:
|
|
65
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@settings_router.post("/knowledge-bases/preview-move")
|
|
69
|
+
async def preview_knowledge_move(payload: KnowledgeMovePayload) -> dict:
|
|
70
|
+
from deeptutor.services.workspace.kb_move import preview_kb_move
|
|
71
|
+
|
|
72
|
+
return await _data_operation(preview_kb_move, payload.source_id, payload.target_workspace_id)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@settings_router.post("/knowledge-bases/move")
|
|
76
|
+
async def move_knowledge_base(payload: KnowledgeMovePayload) -> dict:
|
|
77
|
+
from deeptutor.services.workspace.kb_move import move_kb
|
|
78
|
+
|
|
79
|
+
return await _data_operation(move_kb, payload.source_id, payload.target_workspace_id)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _assert_migration_access():
|
|
83
|
+
from deeptutor.multi_user.learning_access import current_learning_policy
|
|
84
|
+
|
|
85
|
+
if current_learning_policy() is not None:
|
|
86
|
+
raise HTTPException(
|
|
87
|
+
status_code=403,
|
|
88
|
+
detail="Data migration and export are managed by the guardian for this learning account.",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@settings_router.get("/data/discover")
|
|
93
|
+
async def discover_workspace_data(source_workspace_id: str = "") -> dict:
|
|
94
|
+
from deeptutor.services.workspace.data_migration import discover
|
|
95
|
+
|
|
96
|
+
return await _data_operation(discover, source_workspace_id)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@settings_router.post("/data/preview")
|
|
100
|
+
async def preview_workspace_data(payload: DataMigrationPayload) -> dict:
|
|
101
|
+
from deeptutor.services.workspace.data_migration import preview
|
|
102
|
+
|
|
103
|
+
return await _data_operation(
|
|
104
|
+
preview, payload.source_workspace_id, payload.target_workspace_id, payload.features
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@settings_router.post("/data/migrate")
|
|
109
|
+
async def migrate_workspace_data(payload: DataMigrationPayload) -> dict:
|
|
110
|
+
from deeptutor.services.workspace.data_migration import migrate_data
|
|
111
|
+
|
|
112
|
+
return await _data_operation(
|
|
113
|
+
migrate_data, payload.source_workspace_id, payload.target_workspace_id, payload.features
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@settings_router.post("/data/export")
|
|
118
|
+
async def export_workspace_data(payload: DataMigrationPayload) -> dict:
|
|
119
|
+
from deeptutor.services.workspace.data_migration import export_data
|
|
120
|
+
|
|
121
|
+
return await _data_operation(
|
|
122
|
+
export_data,
|
|
123
|
+
payload.source_workspace_id,
|
|
124
|
+
payload.features,
|
|
125
|
+
include_historical=payload.include_historical,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@settings_router.get("/data/operations")
|
|
130
|
+
async def workspace_data_operations() -> dict:
|
|
131
|
+
_assert_migration_access()
|
|
132
|
+
from deeptutor.services.workspace.data_migration import operations
|
|
133
|
+
|
|
134
|
+
return {"operations": await asyncio.to_thread(operations)}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@settings_router.post("/data/operations/{operation_id}/recover")
|
|
138
|
+
async def recover_workspace_migration(operation_id: str) -> dict:
|
|
139
|
+
from deeptutor.services.workspace.data_migration import recover_operation
|
|
140
|
+
|
|
141
|
+
return await _data_operation(recover_operation, operation_id)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@settings_router.get("/data/exports/{operation_id}")
|
|
145
|
+
async def download_workspace_export(operation_id: str) -> FileResponse:
|
|
146
|
+
_assert_migration_access()
|
|
147
|
+
from deeptutor.services.workspace.data_migration import export_path
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
return FileResponse(
|
|
151
|
+
export_path(operation_id),
|
|
152
|
+
media_type="application/zip",
|
|
153
|
+
filename=f"deeptutor-data-{operation_id[:8]}.zip",
|
|
154
|
+
)
|
|
155
|
+
except WorkspaceError as exc:
|
|
156
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@settings_router.get("/registrations")
|
|
160
|
+
async def list_workspaces() -> dict:
|
|
161
|
+
return get_content_workspace_service().describe_catalog()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class WorkspaceMigrationPayload(BaseModel):
|
|
165
|
+
path: str = Field(min_length=1)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _migrate_workspaces(path: str, workspace_id: str | None = None) -> dict:
|
|
169
|
+
from deeptutor.services.session import get_session_store
|
|
170
|
+
|
|
171
|
+
service = get_content_workspace_service()
|
|
172
|
+
|
|
173
|
+
async def perform() -> dict:
|
|
174
|
+
from deeptutor.services.workspace.activity import data_activity
|
|
175
|
+
from deeptutor.services.workspace.data_migration import assert_no_pending_recovery
|
|
176
|
+
|
|
177
|
+
with data_activity(exclusive=True):
|
|
178
|
+
assert_no_pending_recovery(reject_unreadable=True)
|
|
179
|
+
return await migrate_location()
|
|
180
|
+
|
|
181
|
+
async def migrate_location() -> dict:
|
|
182
|
+
with service.maintenance():
|
|
183
|
+
active = await get_session_store().list_nonterminal_turns()
|
|
184
|
+
if active:
|
|
185
|
+
raise WorkspaceError(
|
|
186
|
+
"Wait for running conversations to finish before migrating workspace storage."
|
|
187
|
+
)
|
|
188
|
+
if workspace_id is None:
|
|
189
|
+
return await asyncio.to_thread(service.migrate_root, path)
|
|
190
|
+
return await asyncio.to_thread(service.migrate_workspace, workspace_id, path)
|
|
191
|
+
|
|
192
|
+
# A disconnected browser must not abandon a copy midway or release the
|
|
193
|
+
# migration guard while its worker is still copying files.
|
|
194
|
+
task = asyncio.create_task(perform())
|
|
195
|
+
# Retrieve a late error even if the requesting client has disconnected.
|
|
196
|
+
task.add_done_callback(lambda done: done.exception() if not done.cancelled() else None)
|
|
197
|
+
try:
|
|
198
|
+
return await asyncio.shield(task)
|
|
199
|
+
except (WorkspaceError, OSError, ValueError) as exc:
|
|
200
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@settings_router.post("/registrations/migrate-root")
|
|
204
|
+
async def migrate_workspace_root(payload: WorkspaceMigrationPayload) -> dict:
|
|
205
|
+
return await _migrate_workspaces(payload.path)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@settings_router.post("/registrations/{workspace_id}/migrate")
|
|
209
|
+
async def migrate_registered_workspace(
|
|
210
|
+
workspace_id: str, payload: WorkspaceMigrationPayload
|
|
211
|
+
) -> dict:
|
|
212
|
+
return await _migrate_workspaces(payload.path, workspace_id)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@settings_router.get("/registrations/system-snapshot")
|
|
216
|
+
@settings_router.post("/registrations/system-snapshot")
|
|
217
|
+
async def system_workspace_snapshot() -> dict:
|
|
218
|
+
try:
|
|
219
|
+
return get_content_workspace_service().refresh_system_snapshot()
|
|
220
|
+
except (WorkspaceError, OSError, ValueError) as exc:
|
|
221
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@settings_router.get("/resource-usage")
|
|
225
|
+
async def workspace_resource_usage(kind: str, resource_id: str, skill_workspace: str = "") -> dict:
|
|
226
|
+
from deeptutor.services.workspace.resources import resource_usage
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
return {"workspaces": resource_usage(kind, resource_id, skill_workspace=skill_workspace)}
|
|
230
|
+
except WorkspaceError as exc:
|
|
231
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@settings_router.get("/resources")
|
|
235
|
+
async def workspace_resource_catalog(workspace_id: str = "") -> dict:
|
|
236
|
+
from deeptutor.services.workspace.resources import resource_catalog
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
return await asyncio.to_thread(resource_catalog, workspace_id=workspace_id)
|
|
240
|
+
except WorkspaceError as exc:
|
|
241
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@settings_router.post("/registrations")
|
|
245
|
+
async def create_workspace(payload: CreateWorkspacePayload) -> dict:
|
|
246
|
+
try:
|
|
247
|
+
workspace = get_content_workspace_service().create_workspace(
|
|
248
|
+
payload.name,
|
|
249
|
+
payload.path,
|
|
250
|
+
resources=payload.resources.model_dump() if payload.resources else None,
|
|
251
|
+
instructions=payload.instructions,
|
|
252
|
+
)
|
|
253
|
+
except WorkspaceError as exc:
|
|
254
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
255
|
+
return {"workspace": workspace}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@settings_router.patch("/registrations/{workspace_id}")
|
|
259
|
+
async def update_registered_workspace(workspace_id: str, payload: UpdateWorkspacePayload) -> dict:
|
|
260
|
+
try:
|
|
261
|
+
from deeptutor.services.workspace.activity import data_activity
|
|
262
|
+
from deeptutor.services.workspace.data_migration import assert_no_pending_recovery
|
|
263
|
+
|
|
264
|
+
# Instruction edits are atomic metadata updates. A running turn owns
|
|
265
|
+
# its admitted snapshot, so these edits can coexist with its data lease.
|
|
266
|
+
changes = payload.model_dump(exclude_unset=True)
|
|
267
|
+
with data_activity(exclusive=set(changes) != {"instructions"}):
|
|
268
|
+
assert_no_pending_recovery(reject_unreadable=True)
|
|
269
|
+
workspace = get_content_workspace_service().update_workspace(workspace_id, **changes)
|
|
270
|
+
except WorkspaceError as exc:
|
|
271
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
272
|
+
return {"workspace": workspace}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@settings_router.get("")
|
|
276
|
+
async def get_workspace_settings() -> dict:
|
|
277
|
+
return get_content_workspace_service().describe_current()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@settings_router.post("/validate")
|
|
281
|
+
async def validate_workspace(payload: WorkspacePathPayload) -> dict:
|
|
282
|
+
return get_content_workspace_service().validate(payload.path)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@settings_router.put("")
|
|
286
|
+
async def update_workspace(payload: WorkspacePathPayload) -> dict:
|
|
287
|
+
service = get_content_workspace_service()
|
|
288
|
+
try:
|
|
289
|
+
service.set_workspace(payload.path)
|
|
290
|
+
except WorkspaceError as exc:
|
|
291
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
292
|
+
return service.describe_current()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _resolve_partner_item(
|
|
296
|
+
workspace_id: str, workspace_item_id: str
|
|
297
|
+
) -> tuple[Path, WorkspaceItem] | None:
|
|
298
|
+
"""Resolve a presentation one of the caller's partners published.
|
|
299
|
+
|
|
300
|
+
A partner web chat runs inside a synthetic workspace under
|
|
301
|
+
``data/partners/<id>/workspace``, so ``workspace_present`` writes its
|
|
302
|
+
manifest to that scope's private presentation store. The human caller's own
|
|
303
|
+
bindings never name that workspace, ``binding_by_id`` refuses it, and the
|
|
304
|
+
attachment sitting in the transcript 404s on click (#1267).
|
|
305
|
+
|
|
306
|
+
``/files/outputs`` grew the same reach in #1012 — see
|
|
307
|
+
``_resolve_partner_output`` — and this is the other artifact URL shape. It
|
|
308
|
+
does not need that endpoint's unique-match rule: a workspace id is derived
|
|
309
|
+
from the owning user id together with the physical root and re-derived
|
|
310
|
+
before a stored row is trusted, so a hit inside a partner scope belongs to
|
|
311
|
+
that partner by construction, where a relative output path can collide.
|
|
312
|
+
"""
|
|
313
|
+
from deeptutor.services.workspace.context import current_workspace_id
|
|
314
|
+
|
|
315
|
+
if current_workspace_id():
|
|
316
|
+
return None
|
|
317
|
+
for partner in visible_partners():
|
|
318
|
+
partner_id = str(partner.get("partner_id") or "").strip()
|
|
319
|
+
if not partner_id:
|
|
320
|
+
continue
|
|
321
|
+
from deeptutor.services.partners.scope import partner_user
|
|
322
|
+
|
|
323
|
+
with user_context(partner_user(partner_id)):
|
|
324
|
+
try:
|
|
325
|
+
return get_content_workspace_service().resolve_published_item(
|
|
326
|
+
workspace_id, workspace_item_id
|
|
327
|
+
)
|
|
328
|
+
except WorkspaceError:
|
|
329
|
+
continue
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@files_router.get("/{workspace_id}/{workspace_item_id}", operation_id="read_workspace_item_get")
|
|
334
|
+
@files_router.head("/{workspace_id}/{workspace_item_id}", operation_id="read_workspace_item_head")
|
|
335
|
+
async def read_workspace_item(
|
|
336
|
+
workspace_id: str,
|
|
337
|
+
workspace_item_id: str,
|
|
338
|
+
_auth: TokenPayload | None = Depends(require_auth),
|
|
339
|
+
) -> FileResponse:
|
|
340
|
+
try:
|
|
341
|
+
path, item = get_content_workspace_service().resolve_published_item(
|
|
342
|
+
workspace_id, workspace_item_id
|
|
343
|
+
)
|
|
344
|
+
except WorkspaceError as exc:
|
|
345
|
+
resolved = _resolve_partner_item(workspace_id, workspace_item_id)
|
|
346
|
+
if resolved is None:
|
|
347
|
+
raise HTTPException(
|
|
348
|
+
status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
|
|
349
|
+
) from exc
|
|
350
|
+
path, item = resolved
|
|
351
|
+
return FileResponse(
|
|
352
|
+
path,
|
|
353
|
+
media_type=item.mime_type,
|
|
354
|
+
filename=item.filename,
|
|
355
|
+
content_disposition_type="inline",
|
|
356
|
+
headers={
|
|
357
|
+
"ETag": f'"{item.sha256}"',
|
|
358
|
+
"Cache-Control": "private, max-age=31536000, immutable",
|
|
359
|
+
"X-Content-Type-Options": "nosniff",
|
|
360
|
+
# Presented files are user/model-controlled. Opening an HTML or
|
|
361
|
+
# SVG snapshot directly on the app origin must never execute it
|
|
362
|
+
# with access to DeepTutor's authenticated origin.
|
|
363
|
+
"Content-Security-Policy": (
|
|
364
|
+
"sandbox; default-src 'none'; img-src data: blob:; "
|
|
365
|
+
"media-src 'self' blob:; style-src 'unsafe-inline'"
|
|
366
|
+
),
|
|
367
|
+
},
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
__all__ = ["files_router", "settings_router"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""
|
|
3
|
+
Uvicorn Server Startup Script
|
|
4
|
+
Uses Python API instead of command line to avoid Windows path parsing issues.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from deeptutor.runtime.home import get_runtime_home
|
|
13
|
+
|
|
14
|
+
# Windows: uvicorn defaults to SelectorEventLoop which does not support
|
|
15
|
+
# asyncio.create_subprocess_exec. Switch to ProactorEventLoop so that
|
|
16
|
+
# child-process APIs (used by Math Animator renderer, etc.) work correctly.
|
|
17
|
+
if sys.platform == "win32":
|
|
18
|
+
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
|
19
|
+
|
|
20
|
+
import uvicorn
|
|
21
|
+
|
|
22
|
+
# Force unbuffered output
|
|
23
|
+
os.environ["PYTHONUNBUFFERED"] = "1"
|
|
24
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
25
|
+
sys.stdout.reconfigure(line_buffering=True, encoding="utf-8", errors="replace")
|
|
26
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
27
|
+
sys.stderr.reconfigure(line_buffering=True, encoding="utf-8", errors="replace")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> None:
|
|
31
|
+
# Runtime workspace root owns data/user/settings and generated outputs.
|
|
32
|
+
project_root = get_runtime_home()
|
|
33
|
+
os.chdir(str(project_root))
|
|
34
|
+
|
|
35
|
+
# Get port from configuration
|
|
36
|
+
from deeptutor.logging import configure_logging
|
|
37
|
+
from deeptutor.runtime.mode import RunMode, set_mode
|
|
38
|
+
from deeptutor.services.config import (
|
|
39
|
+
HTTP_KEEP_ALIVE_TIMEOUT,
|
|
40
|
+
get_ws_max_size,
|
|
41
|
+
load_system_settings,
|
|
42
|
+
)
|
|
43
|
+
from deeptutor.services.setup import get_backend_port
|
|
44
|
+
|
|
45
|
+
set_mode(RunMode.SERVER)
|
|
46
|
+
configure_logging()
|
|
47
|
+
backend_port = get_backend_port(project_root)
|
|
48
|
+
|
|
49
|
+
# Configure reload_excludes to skip directories that shouldn't trigger reloads
|
|
50
|
+
# Use absolute paths to ensure they're properly resolved
|
|
51
|
+
reload_excludes = [
|
|
52
|
+
str(project_root / "venv"), # Virtual environment
|
|
53
|
+
str(project_root / ".venv"), # Virtual environment (alternative name)
|
|
54
|
+
str(project_root / "data"), # Data directory (includes knowledge_bases, user data, logs)
|
|
55
|
+
str(project_root / "node_modules"), # Node modules (if any at root)
|
|
56
|
+
str(project_root / "web" / "node_modules"), # Web node modules
|
|
57
|
+
str(project_root / "web" / ".next"), # Next.js build
|
|
58
|
+
str(project_root / ".git"), # Git directory
|
|
59
|
+
str(project_root / "scripts"), # Scripts directory - don't reload on launcher changes
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
# Filter out non-existent directories to avoid warnings
|
|
63
|
+
reload_excludes = [d for d in reload_excludes if Path(d).exists()]
|
|
64
|
+
|
|
65
|
+
# Reload launches a supervisor plus a worker and retains file-watcher state,
|
|
66
|
+
# so keep it opt-in for local development. ws_max_size tracks the configured
|
|
67
|
+
# chat-attachment total so base64 uploads fit in one WS frame.
|
|
68
|
+
dev_reload = os.environ.get("DEEPTUTOR_DEV_RELOAD", "").strip().lower() in {
|
|
69
|
+
"1",
|
|
70
|
+
"true",
|
|
71
|
+
"yes",
|
|
72
|
+
"on",
|
|
73
|
+
}
|
|
74
|
+
backend_workers = max(1, int(load_system_settings().get("backend_workers") or 1))
|
|
75
|
+
if dev_reload and backend_workers > 1:
|
|
76
|
+
raise SystemExit(
|
|
77
|
+
"Development reload and backend_workers > 1 are mutually exclusive. "
|
|
78
|
+
"Set backend_workers=1 or disable DEEPTUTOR_DEV_RELOAD."
|
|
79
|
+
)
|
|
80
|
+
uvicorn.run(
|
|
81
|
+
"deeptutor.api.main:app",
|
|
82
|
+
host="0.0.0.0",
|
|
83
|
+
port=backend_port,
|
|
84
|
+
reload=dev_reload,
|
|
85
|
+
workers=backend_workers,
|
|
86
|
+
reload_excludes=reload_excludes if dev_reload else None,
|
|
87
|
+
log_level="info",
|
|
88
|
+
access_log=False,
|
|
89
|
+
# XFF is client-controlled in this deployment. Keep request.client tied
|
|
90
|
+
# to the actual backend peer for the selective access log.
|
|
91
|
+
proxy_headers=False,
|
|
92
|
+
ws_max_size=get_ws_max_size(),
|
|
93
|
+
timeout_keep_alive=HTTP_KEEP_ALIVE_TIMEOUT,
|
|
94
|
+
timeout_graceful_shutdown=1,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
main()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Opt-in request screening with fastapi-guard.
|
|
2
|
+
|
|
3
|
+
Disabled by default. When ``system.json`` -> ``security_guard.enabled`` is false
|
|
4
|
+
(or ``DEEPTUTOR_GUARD_ENABLED`` is unset), nothing here imports ``guard`` and the
|
|
5
|
+
middleware stack is unchanged. See ``docs-for-user/SECURITY_GUARD.md``.
|
|
6
|
+
|
|
7
|
+
The guard runs inside the application, in front of the routers. It screens HTTP
|
|
8
|
+
requests by client address (rate limit, IP allow/deny lists, ban list) and scans
|
|
9
|
+
the URL path, query string and headers for attack patterns. It does not read
|
|
10
|
+
request bodies (prompts, documents and chat are free text), does not touch
|
|
11
|
+
WebSocket handshakes, and does not use Redis, CORS or security headers.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from fastapi import FastAPI
|
|
20
|
+
|
|
21
|
+
from deeptutor.services.config import load_system_settings
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
INSTALL_HINT = (
|
|
26
|
+
"security_guard is enabled but the 'fastapi-guard' package is not installed. "
|
|
27
|
+
"Install it with `pip install 'deeptutor[guard]'` (Docker: set "
|
|
28
|
+
"DEEPTUTOR_EXTRAS=guard), or disable it with security_guard.enabled=false in "
|
|
29
|
+
"system.json / DEEPTUTOR_GUARD_ENABLED=false."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_guard_settings() -> dict[str, Any]:
|
|
34
|
+
"""Effective ``security_guard`` settings (file plus deployment overrides)."""
|
|
35
|
+
return dict(load_system_settings()["security_guard"])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_security_config(settings: dict[str, Any]) -> Any:
|
|
39
|
+
"""Translate DeepTutor settings into a ``guard.SecurityConfig``."""
|
|
40
|
+
try:
|
|
41
|
+
from guard import SecurityConfig
|
|
42
|
+
except ImportError as exc:
|
|
43
|
+
raise RuntimeError(INSTALL_HINT) from exc
|
|
44
|
+
from guard_core.handlers.suspatterns_handler import ALL_DETECTION_CATEGORIES
|
|
45
|
+
|
|
46
|
+
return SecurityConfig(
|
|
47
|
+
rate_limit=settings["rate_limit"],
|
|
48
|
+
rate_limit_window=settings["rate_limit_window"],
|
|
49
|
+
trusted_proxies=tuple(settings["trusted_proxies"]),
|
|
50
|
+
exempt_ips=tuple(settings["exempt_ips"]),
|
|
51
|
+
whitelist=tuple(settings["whitelist"]) or None,
|
|
52
|
+
blacklist=tuple(settings["blacklist"]),
|
|
53
|
+
# Process-local state: each uvicorn worker keeps its own counters.
|
|
54
|
+
enable_redis=False,
|
|
55
|
+
# DeepTutor sets CORS itself (CORSMiddleware) and owns its response headers.
|
|
56
|
+
enable_cors=False,
|
|
57
|
+
security_headers={"enabled": False},
|
|
58
|
+
# Prompts, documents and chat messages are free text, not attack input.
|
|
59
|
+
detection_scan_body=False,
|
|
60
|
+
# Browsers send the page URL as Referer; guard-core 4.4.0 flags localhost and
|
|
61
|
+
# LAN addresses in it as ssrf (400), so that category stays off.
|
|
62
|
+
enabled_detection_categories=ALL_DETECTION_CATEGORIES - {"ssrf"},
|
|
63
|
+
enable_agent=False,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def install_security_guard(app: FastAPI, settings: dict[str, Any] | None = None) -> bool:
|
|
68
|
+
"""Add the guard middleware to ``app`` when enabled; return whether it was added.
|
|
69
|
+
|
|
70
|
+
Call this before ``CORSMiddleware`` is added: Starlette puts the last-added
|
|
71
|
+
middleware outermost, so CORS stays outside the guard and 429/403 responses
|
|
72
|
+
still carry CORS headers.
|
|
73
|
+
"""
|
|
74
|
+
settings = load_guard_settings() if settings is None else settings
|
|
75
|
+
if not settings["enabled"]:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
config = build_security_config(settings)
|
|
79
|
+
from guard import PureASGISecurityMiddleware
|
|
80
|
+
|
|
81
|
+
app.add_middleware(PureASGISecurityMiddleware, config=config)
|
|
82
|
+
logger.info(
|
|
83
|
+
"Security guard enabled: rate_limit=%s/%ss trusted_proxies=%s exempt_ips=%s whitelist=%s blacklist=%s",
|
|
84
|
+
settings["rate_limit"],
|
|
85
|
+
settings["rate_limit_window"],
|
|
86
|
+
settings["trusted_proxies"],
|
|
87
|
+
settings["exempt_ips"],
|
|
88
|
+
len(settings["whitelist"]),
|
|
89
|
+
len(settings["blacklist"]),
|
|
90
|
+
)
|
|
91
|
+
return True
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Shared HTTP header builders.
|
|
2
|
+
|
|
3
|
+
Small, boring, and easy to get wrong in ways that only show up for non-English
|
|
4
|
+
users — so they live in one place rather than once per router.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def content_disposition(filename: str, *, disposition: str = "inline") -> str:
|
|
13
|
+
"""Build a Content-Disposition header that survives non-ASCII filenames.
|
|
14
|
+
|
|
15
|
+
HTTP/1.1 headers are latin-1, so dropping a Chinese / accented filename
|
|
16
|
+
straight into ``filename="..."`` blows up with UnicodeEncodeError. RFC
|
|
17
|
+
6266 / RFC 5987 cover this: emit ``filename*=UTF-8''<percent-encoded>``
|
|
18
|
+
plus an ASCII fallback on ``filename=`` for legacy clients.
|
|
19
|
+
"""
|
|
20
|
+
ascii_fallback = filename.encode("ascii", errors="replace").decode("ascii")
|
|
21
|
+
# Quotes / backslashes break the simple-quoted-string form; collapse them.
|
|
22
|
+
ascii_fallback = ascii_fallback.replace('"', "_").replace("\\", "_")
|
|
23
|
+
encoded = quote(filename, safe="")
|
|
24
|
+
return f"{disposition}; filename=\"{ascii_fallback}\"; filename*=UTF-8''{encoded}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["content_disposition"]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Progress Broadcaster - Manages WebSocket broadcasting of knowledge base progress
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import WebSocket
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProgressBroadcaster:
|
|
15
|
+
"""Manages WebSocket broadcasting of knowledge base progress"""
|
|
16
|
+
|
|
17
|
+
_instance: Optional["ProgressBroadcaster"] = None
|
|
18
|
+
_connections: dict[str, set[WebSocket]] = {} # kb_name -> Set[WebSocket]
|
|
19
|
+
_lock = asyncio.Lock()
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def get_instance(cls) -> "ProgressBroadcaster":
|
|
23
|
+
"""Get singleton instance"""
|
|
24
|
+
if cls._instance is None:
|
|
25
|
+
cls._instance = cls()
|
|
26
|
+
return cls._instance
|
|
27
|
+
|
|
28
|
+
async def connect(self, kb_name: str, websocket: WebSocket):
|
|
29
|
+
"""Connect WebSocket to specified knowledge base"""
|
|
30
|
+
async with self._lock:
|
|
31
|
+
if kb_name not in self._connections:
|
|
32
|
+
self._connections[kb_name] = set()
|
|
33
|
+
self._connections[kb_name].add(websocket)
|
|
34
|
+
logger.debug(
|
|
35
|
+
f"Connected WebSocket for KB '{kb_name}' (total: {len(self._connections[kb_name])})"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
async def disconnect(self, kb_name: str, websocket: WebSocket):
|
|
39
|
+
"""Disconnect WebSocket connection"""
|
|
40
|
+
async with self._lock:
|
|
41
|
+
if kb_name in self._connections:
|
|
42
|
+
self._connections[kb_name].discard(websocket)
|
|
43
|
+
if not self._connections[kb_name]:
|
|
44
|
+
del self._connections[kb_name]
|
|
45
|
+
logger.debug(f"Disconnected WebSocket for KB '{kb_name}'")
|
|
46
|
+
|
|
47
|
+
async def broadcast(self, kb_name: str, progress: dict):
|
|
48
|
+
"""Broadcast progress update to all WebSocket connections for specified knowledge base"""
|
|
49
|
+
async with self._lock:
|
|
50
|
+
if kb_name not in self._connections:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
# Create list of connections to remove (closed connections)
|
|
54
|
+
to_remove = []
|
|
55
|
+
|
|
56
|
+
for websocket in self._connections[kb_name]:
|
|
57
|
+
try:
|
|
58
|
+
await websocket.send_json({"type": "progress", "data": progress})
|
|
59
|
+
except Exception as e:
|
|
60
|
+
# Connection closed or error, mark for removal
|
|
61
|
+
logger.debug(f"Error sending to WebSocket for KB '{kb_name}': {e}")
|
|
62
|
+
to_remove.append(websocket)
|
|
63
|
+
|
|
64
|
+
# Remove closed connections
|
|
65
|
+
for ws in to_remove:
|
|
66
|
+
self._connections[kb_name].discard(ws)
|
|
67
|
+
|
|
68
|
+
if not self._connections[kb_name]:
|
|
69
|
+
del self._connections[kb_name]
|
|
70
|
+
|
|
71
|
+
def get_connection_count(self, kb_name: str) -> int:
|
|
72
|
+
"""Get connection count for specified knowledge base"""
|
|
73
|
+
return len(self._connections.get(kb_name, set()))
|