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
deeptutor/api/main.py
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, Request
|
|
7
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
8
|
+
from fastapi.responses import JSONResponse, RedirectResponse
|
|
9
|
+
|
|
10
|
+
from deeptutor.api.security_guard import install_security_guard
|
|
11
|
+
from deeptutor.logging import configure_logging
|
|
12
|
+
from deeptutor.modules.catalog import module_enabled
|
|
13
|
+
from deeptutor.services.config import (
|
|
14
|
+
ensure_runtime_settings_files,
|
|
15
|
+
export_runtime_settings_to_env,
|
|
16
|
+
load_auth_settings,
|
|
17
|
+
load_system_settings,
|
|
18
|
+
)
|
|
19
|
+
from deeptutor.services.config.origins import normalize_origins
|
|
20
|
+
from deeptutor.services.path_service import get_path_service
|
|
21
|
+
|
|
22
|
+
ensure_runtime_settings_files()
|
|
23
|
+
export_runtime_settings_to_env(overwrite=True)
|
|
24
|
+
configure_logging()
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _SuppressWsNoise(logging.Filter):
|
|
29
|
+
"""Suppress noisy uvicorn logs for WebSocket connection churn."""
|
|
30
|
+
|
|
31
|
+
_SUPPRESSED = ("connection open", "connection closed")
|
|
32
|
+
|
|
33
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
34
|
+
msg = record.getMessage()
|
|
35
|
+
return not any(f in msg for f in self._SUPPRESSED)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
logging.getLogger("uvicorn.error").addFilter(_SuppressWsNoise())
|
|
39
|
+
|
|
40
|
+
CONFIG_DRIFT_ERROR_TEMPLATE = (
|
|
41
|
+
"Configuration Drift Detected: Capability tool references {drift} are not "
|
|
42
|
+
"registered in the runtime tool registry. Register the missing tools or "
|
|
43
|
+
"remove the stale tool names from the capability manifests."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_tool_consistency():
|
|
48
|
+
"""
|
|
49
|
+
Validate that capability manifests only reference tools that are actually
|
|
50
|
+
registered in the runtime ``ToolRegistry``.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
from deeptutor.runtime.registry.capability_registry import get_capability_registry
|
|
54
|
+
from deeptutor.runtime.registry.tool_registry import get_tool_registry
|
|
55
|
+
|
|
56
|
+
capability_registry = get_capability_registry()
|
|
57
|
+
tool_registry = get_tool_registry()
|
|
58
|
+
from deeptutor.tools.builtin_specs import BUILTIN_TOOL_NAMES
|
|
59
|
+
|
|
60
|
+
available_tools = set(tool_registry.list_tools()) | set(BUILTIN_TOOL_NAMES)
|
|
61
|
+
|
|
62
|
+
referenced_tools = set()
|
|
63
|
+
for manifest in capability_registry.get_manifests():
|
|
64
|
+
referenced_tools.update(manifest.get("tools_used", []) or [])
|
|
65
|
+
|
|
66
|
+
drift = referenced_tools - available_tools
|
|
67
|
+
if drift:
|
|
68
|
+
raise RuntimeError(CONFIG_DRIFT_ERROR_TEMPLATE.format(drift=drift))
|
|
69
|
+
except RuntimeError:
|
|
70
|
+
logger.exception("Configuration validation failed")
|
|
71
|
+
raise
|
|
72
|
+
except Exception:
|
|
73
|
+
logger.exception("Failed to load configuration for validation")
|
|
74
|
+
raise
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _build_cors_settings() -> dict[str, object]:
|
|
78
|
+
"""Build CORS settings for both localhost and remote Docker deployments."""
|
|
79
|
+
system_settings = load_system_settings()
|
|
80
|
+
auth_settings = load_auth_settings()
|
|
81
|
+
frontend_port = str(system_settings["frontend_port"])
|
|
82
|
+
extra_origins = normalize_origins(
|
|
83
|
+
[system_settings["cors_origin"], system_settings["cors_origins"]]
|
|
84
|
+
)
|
|
85
|
+
origins = [
|
|
86
|
+
f"http://localhost:{frontend_port}",
|
|
87
|
+
f"http://127.0.0.1:{frontend_port}",
|
|
88
|
+
"http://localhost:3000",
|
|
89
|
+
"http://127.0.0.1:3000",
|
|
90
|
+
]
|
|
91
|
+
for origin in extra_origins:
|
|
92
|
+
if origin not in origins:
|
|
93
|
+
origins.append(origin)
|
|
94
|
+
|
|
95
|
+
# Auth is disabled by default. In that local/single-user mode, mirror the
|
|
96
|
+
# pre-v1.3.8 behavior and allow remote Docker/LAN origins out of the box.
|
|
97
|
+
# When auth is enabled, require explicit CORS_ORIGIN(S) for credentialed
|
|
98
|
+
# cross-origin requests.
|
|
99
|
+
allow_origin_regex = None if auth_settings["enabled"] else r"https?://.*"
|
|
100
|
+
mode = "explicit" if auth_settings["enabled"] else "permissive"
|
|
101
|
+
return {
|
|
102
|
+
"allow_origins": origins,
|
|
103
|
+
"allow_origin_regex": allow_origin_regex,
|
|
104
|
+
"mode": mode,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@asynccontextmanager
|
|
109
|
+
async def lifespan(app: FastAPI):
|
|
110
|
+
"""
|
|
111
|
+
Application lifecycle management
|
|
112
|
+
Gracefully handle startup and shutdown events, avoid CancelledError
|
|
113
|
+
"""
|
|
114
|
+
# Execute on startup
|
|
115
|
+
logger.info("Application startup")
|
|
116
|
+
app.state.ready = False
|
|
117
|
+
|
|
118
|
+
# Validate configuration consistency
|
|
119
|
+
validate_tool_consistency()
|
|
120
|
+
|
|
121
|
+
# Build one process-level dependency graph. Every adapter resolves turns
|
|
122
|
+
# through this container, so a WebSocket cannot accidentally construct a
|
|
123
|
+
# second store/runtime for the same authenticated scope.
|
|
124
|
+
from deeptutor.app.container import get_application_container
|
|
125
|
+
|
|
126
|
+
application_container = getattr(app.state, "application_container", None)
|
|
127
|
+
if application_container is None:
|
|
128
|
+
application_container = get_application_container()
|
|
129
|
+
app.state.application_container = application_container
|
|
130
|
+
await application_container.start()
|
|
131
|
+
if module_enabled("knowledge"):
|
|
132
|
+
from deeptutor.api.utils.progress_broadcaster import ProgressBroadcaster
|
|
133
|
+
from deeptutor.api.utils.task_log_stream import get_task_stream_manager
|
|
134
|
+
from deeptutor.knowledge.progress_events import install_progress_ports
|
|
135
|
+
|
|
136
|
+
install_progress_ports(
|
|
137
|
+
broadcast=ProgressBroadcaster.get_instance().broadcast,
|
|
138
|
+
emit_task_event=get_task_stream_manager().emit,
|
|
139
|
+
)
|
|
140
|
+
migration_reports = await application_container.run_startup_data_migrations()
|
|
141
|
+
legacy_reports = migration_reports["legacy_chat"]
|
|
142
|
+
migrated_reports = [report for report in legacy_reports if report.get("source_hash")]
|
|
143
|
+
if migrated_reports:
|
|
144
|
+
logger.info(
|
|
145
|
+
"Legacy chat migration complete: imported=%s skipped=%s archived=%s",
|
|
146
|
+
sum(int(report.get("imported") or 0) for report in migrated_reports),
|
|
147
|
+
sum(int(report.get("skipped") or 0) for report in migrated_reports),
|
|
148
|
+
[report.get("archived_to", "") for report in migrated_reports],
|
|
149
|
+
)
|
|
150
|
+
workspace_migrated = sum(
|
|
151
|
+
int(report.get("migrated") or 0) for report in migration_reports["workspace_preferences"]
|
|
152
|
+
)
|
|
153
|
+
if workspace_migrated:
|
|
154
|
+
logger.info(
|
|
155
|
+
"Workspace session migration complete: migrated=%s scopes=%s",
|
|
156
|
+
workspace_migrated,
|
|
157
|
+
len(migration_reports["workspace_preferences"]),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
if module_enabled("mastery"):
|
|
162
|
+
from deeptutor.learning.assessment import reconcile_linked_assessments
|
|
163
|
+
|
|
164
|
+
recovered, failed = await reconcile_linked_assessments()
|
|
165
|
+
if recovered or failed:
|
|
166
|
+
logger.info("Linked assessment recovery: recovered=%s failed=%s", recovered, failed)
|
|
167
|
+
except Exception:
|
|
168
|
+
logger.exception("Failed to reconcile linked assessments at startup")
|
|
169
|
+
|
|
170
|
+
# Initialize LLM client early so OPENAI_* env vars are available before
|
|
171
|
+
# any downstream provider integrations start.
|
|
172
|
+
try:
|
|
173
|
+
from deeptutor.services.llm import get_llm_client
|
|
174
|
+
|
|
175
|
+
llm_client = get_llm_client()
|
|
176
|
+
logger.info(f"LLM client initialized: model={llm_client.config.model}")
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.warning(f"Failed to initialize LLM client at startup: {e}")
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
from deeptutor.events.event_bus import get_event_bus
|
|
182
|
+
|
|
183
|
+
event_bus = get_event_bus()
|
|
184
|
+
await event_bus.start()
|
|
185
|
+
logger.info("EventBus started")
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.warning(f"Failed to start EventBus: {e}")
|
|
188
|
+
|
|
189
|
+
from deeptutor.runtime.coordination import BackgroundCommandKind
|
|
190
|
+
|
|
191
|
+
async def _handle_background_command(command) -> None:
|
|
192
|
+
kind = str(command.kind)
|
|
193
|
+
if kind == BackgroundCommandKind.CRON_RELOAD:
|
|
194
|
+
from deeptutor.modules.catalog import require_module
|
|
195
|
+
|
|
196
|
+
require_module("calendar")
|
|
197
|
+
from deeptutor.services.cron import get_cron_service
|
|
198
|
+
|
|
199
|
+
get_cron_service().reload()
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
from deeptutor.modules.catalog import require_module
|
|
203
|
+
|
|
204
|
+
require_module("partners")
|
|
205
|
+
from deeptutor.services.partners import get_partner_manager
|
|
206
|
+
|
|
207
|
+
manager = get_partner_manager()
|
|
208
|
+
manager.configure_runtime_owner(application_container.worker_id)
|
|
209
|
+
partner_id = str(command.payload.get("partner_id") or "").strip()
|
|
210
|
+
if not partner_id:
|
|
211
|
+
raise ValueError(f"Background command {kind!r} needs partner_id")
|
|
212
|
+
if kind == BackgroundCommandKind.PARTNER_START:
|
|
213
|
+
instance = await manager.start_partner(partner_id)
|
|
214
|
+
if bool(command.payload.get("persist_auto_start", True)):
|
|
215
|
+
manager.save_config(partner_id, instance.config, auto_start=True)
|
|
216
|
+
return
|
|
217
|
+
if kind == BackgroundCommandKind.PARTNER_STOP:
|
|
218
|
+
await manager.stop_partner(
|
|
219
|
+
partner_id,
|
|
220
|
+
preserve_auto_start=bool(command.payload.get("preserve_auto_start", False)),
|
|
221
|
+
)
|
|
222
|
+
return
|
|
223
|
+
if kind == BackgroundCommandKind.PARTNER_RELOAD:
|
|
224
|
+
instance = manager.get_partner(partner_id)
|
|
225
|
+
if instance is None or not instance.running:
|
|
226
|
+
return
|
|
227
|
+
config = manager.load_config(partner_id)
|
|
228
|
+
if config is not None:
|
|
229
|
+
instance.config = config
|
|
230
|
+
await manager.reload_channels(partner_id)
|
|
231
|
+
return
|
|
232
|
+
raise ValueError(f"Unknown background command kind: {kind}")
|
|
233
|
+
|
|
234
|
+
cron_service = None
|
|
235
|
+
try:
|
|
236
|
+
if module_enabled("calendar"):
|
|
237
|
+
from deeptutor.services.cron import get_cron_service
|
|
238
|
+
|
|
239
|
+
cron_service = get_cron_service()
|
|
240
|
+
loop = asyncio.get_running_loop()
|
|
241
|
+
|
|
242
|
+
def _notify_cron_change() -> None:
|
|
243
|
+
task = loop.create_task(
|
|
244
|
+
application_container.coordinator.submit_background_command(
|
|
245
|
+
BackgroundCommandKind.CRON_RELOAD
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
task.add_done_callback(
|
|
249
|
+
lambda completed: completed.exception() if not completed.cancelled() else None
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
cron_service.change_notifier = _notify_cron_change
|
|
253
|
+
except Exception:
|
|
254
|
+
logger.exception("Failed to configure cron change notifications")
|
|
255
|
+
|
|
256
|
+
from deeptutor.modules.services import module_service_callbacks
|
|
257
|
+
from deeptutor.runtime.background_leader import BackgroundLeaderSupervisor
|
|
258
|
+
|
|
259
|
+
background_supervisor = BackgroundLeaderSupervisor(
|
|
260
|
+
application_container.coordinator,
|
|
261
|
+
application_container.worker_id,
|
|
262
|
+
start_callbacks=module_service_callbacks("start", application_container),
|
|
263
|
+
stop_callbacks=module_service_callbacks("stop", application_container),
|
|
264
|
+
recovery_callback=application_container.recover_once,
|
|
265
|
+
control_callback=_handle_background_command,
|
|
266
|
+
renew_interval_seconds=application_container.settings.renew_interval_seconds,
|
|
267
|
+
recovery_interval_seconds=application_container.settings.recovery_interval_seconds,
|
|
268
|
+
)
|
|
269
|
+
app.state.background_supervisor = background_supervisor
|
|
270
|
+
await background_supervisor.start()
|
|
271
|
+
|
|
272
|
+
# Ping PocketBase if configured — logs a warning (not an error) if unreachable
|
|
273
|
+
try:
|
|
274
|
+
from deeptutor.services.pocketbase_client import ping_pocketbase
|
|
275
|
+
|
|
276
|
+
await ping_pocketbase()
|
|
277
|
+
except Exception as e:
|
|
278
|
+
logger.warning(f"PocketBase startup check failed: {e}")
|
|
279
|
+
|
|
280
|
+
# Migrate any v1 memory files (PROFILE.md / SOUL.md / SUMMARY.md) into a
|
|
281
|
+
# backup folder so the v2 three-layer subsystem starts clean.
|
|
282
|
+
try:
|
|
283
|
+
if module_enabled("memory"):
|
|
284
|
+
from deeptutor.services.memory import (
|
|
285
|
+
migrate_partner_surface_if_needed,
|
|
286
|
+
migrate_v1_if_needed,
|
|
287
|
+
)
|
|
288
|
+
from deeptutor.services.path_service import get_path_service
|
|
289
|
+
|
|
290
|
+
get_path_service().migrate_legacy_memory_markdown()
|
|
291
|
+
backup = migrate_v1_if_needed()
|
|
292
|
+
if backup is not None:
|
|
293
|
+
logger.info("v1 memory archived to %s", backup)
|
|
294
|
+
# Rename the legacy ``tutorbot`` memory surface (footnote refs, L2
|
|
295
|
+
# doc, snapshot/trace dirs, L3 meta keys) to ``partner``.
|
|
296
|
+
migrate_partner_surface_if_needed()
|
|
297
|
+
except Exception as e:
|
|
298
|
+
logger.warning(f"v1 memory migration failed: {e}")
|
|
299
|
+
|
|
300
|
+
app.state.ready = True
|
|
301
|
+
yield
|
|
302
|
+
|
|
303
|
+
# Execute on shutdown
|
|
304
|
+
app.state.ready = False
|
|
305
|
+
logger.info("Application shutdown")
|
|
306
|
+
|
|
307
|
+
if module_enabled("knowledge"):
|
|
308
|
+
install_progress_ports(broadcast=None, emit_task_event=None)
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
await background_supervisor.close()
|
|
312
|
+
logger.info("Leader-owned background services stopped")
|
|
313
|
+
except Exception as e:
|
|
314
|
+
logger.warning(f"Failed to stop background leader: {e}")
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
await application_container.close()
|
|
318
|
+
logger.info("Application container closed")
|
|
319
|
+
except Exception as e:
|
|
320
|
+
logger.warning(f"Failed to close application container: {e}")
|
|
321
|
+
|
|
322
|
+
# Close MCP server connections. Each one owns an AsyncExitStack inside its
|
|
323
|
+
# own task, so they must be torn down here rather than left to interpreter
|
|
324
|
+
# exit (stdio servers would otherwise leak child processes).
|
|
325
|
+
try:
|
|
326
|
+
from deeptutor.services.mcp import get_mcp_manager
|
|
327
|
+
|
|
328
|
+
await get_mcp_manager().shutdown()
|
|
329
|
+
logger.info("MCP connections closed")
|
|
330
|
+
except Exception as e:
|
|
331
|
+
logger.warning(f"Failed to close MCP connections: {e}")
|
|
332
|
+
|
|
333
|
+
# Close pooled LLM SDK clients so their keep-alive sockets and transports
|
|
334
|
+
# are released deterministically instead of waiting for interpreter GC.
|
|
335
|
+
try:
|
|
336
|
+
from deeptutor.services.llm.provider_factory import close_runtime_provider_pool
|
|
337
|
+
|
|
338
|
+
await close_runtime_provider_pool()
|
|
339
|
+
logger.info("LLM provider pool closed")
|
|
340
|
+
except Exception as e:
|
|
341
|
+
logger.warning(f"Failed to close LLM provider pool: {e}")
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
from deeptutor.runtime.agentic.client import close_agentic_client_pool
|
|
345
|
+
|
|
346
|
+
await close_agentic_client_pool()
|
|
347
|
+
logger.info("Agentic LLM client pool closed")
|
|
348
|
+
except Exception as e:
|
|
349
|
+
logger.warning(f"Failed to close agentic LLM client pool: {e}")
|
|
350
|
+
|
|
351
|
+
# Stop EventBus
|
|
352
|
+
try:
|
|
353
|
+
from deeptutor.events.event_bus import get_event_bus
|
|
354
|
+
|
|
355
|
+
event_bus = get_event_bus()
|
|
356
|
+
await event_bus.stop()
|
|
357
|
+
logger.info("EventBus stopped")
|
|
358
|
+
except Exception as e:
|
|
359
|
+
logger.warning(f"Failed to stop EventBus: {e}")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
from deeptutor.services.workspace.activity import WorkspaceActivityMiddleware
|
|
363
|
+
|
|
364
|
+
app = FastAPI(
|
|
365
|
+
title="DeepTutor API",
|
|
366
|
+
version="1.0.0",
|
|
367
|
+
lifespan=lifespan,
|
|
368
|
+
# Disable automatic trailing slash redirects to prevent protocol downgrade issues
|
|
369
|
+
# when deployed behind HTTPS reverse proxies (e.g., nginx).
|
|
370
|
+
# Without this, FastAPI's 307 redirects may change HTTPS to HTTP.
|
|
371
|
+
# See: https://github.com/HKUDS/DeepTutor/issues/112
|
|
372
|
+
redirect_slashes=False,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
from deeptutor.core.errors import AccessError
|
|
376
|
+
from deeptutor.modules.catalog import ModuleUnavailable
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@app.exception_handler(AccessError)
|
|
380
|
+
async def access_error_handler(request: Request, exc: AccessError):
|
|
381
|
+
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@app.exception_handler(ModuleUnavailable)
|
|
385
|
+
async def module_unavailable_handler(request: Request, exc: ModuleUnavailable):
|
|
386
|
+
return JSONResponse(status_code=409, content={"detail": str(exc)})
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
app.add_middleware(WorkspaceActivityMiddleware)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@app.middleware("http")
|
|
393
|
+
async def json_error_boundary(request: Request, call_next):
|
|
394
|
+
"""Catch-all so 500s always return JSON, never Starlette's plain-text body.
|
|
395
|
+
|
|
396
|
+
Registered as a middleware rather than an ``@app.exception_handler``: a
|
|
397
|
+
handler for ``Exception`` is installed on Starlette's outermost
|
|
398
|
+
``ServerErrorMiddleware``, so its response skips every middleware added
|
|
399
|
+
here — the 500 would carry no CORS headers (a cross-origin caller sees an
|
|
400
|
+
opaque CORS failure instead of this body) and would never reach the access
|
|
401
|
+
log below. Registered *before* ``CORSMiddleware``, this boundary sits
|
|
402
|
+
inside it, so the response travels back out through the normal stack.
|
|
403
|
+
"""
|
|
404
|
+
try:
|
|
405
|
+
return await call_next(request)
|
|
406
|
+
except Exception as exc:
|
|
407
|
+
logger.error(
|
|
408
|
+
"Unhandled exception on %s %s: %s",
|
|
409
|
+
request.method,
|
|
410
|
+
request.url.path,
|
|
411
|
+
exc,
|
|
412
|
+
exc_info=True,
|
|
413
|
+
)
|
|
414
|
+
return JSONResponse(
|
|
415
|
+
status_code=500,
|
|
416
|
+
content={
|
|
417
|
+
"detail": f"{type(exc).__name__}: {exc}",
|
|
418
|
+
"type": type(exc).__name__,
|
|
419
|
+
},
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# Optional fastapi-guard screening (off unless security_guard.enabled). Added
|
|
424
|
+
# before the access log and CORS middlewares below: Starlette makes the last-added
|
|
425
|
+
# middleware outermost, so CORS stays outside the guard (429/403 carry CORS
|
|
426
|
+
# headers) and the access log sees what the guard rejects.
|
|
427
|
+
install_security_guard(app)
|
|
428
|
+
|
|
429
|
+
# Access logging is funneled through this one middleware. uvicorn's own
|
|
430
|
+
# per-request access log is disabled on every launch path (run_server.py via
|
|
431
|
+
# access_log=False; the launcher and Docker via `--no-access-log`), so routine
|
|
432
|
+
# 200s — the chatty frontend polling of /settings, /tools, /knowledge-bases,
|
|
433
|
+
# etc. — never reach the logs. Only non-200s are surfaced, since those are the
|
|
434
|
+
# ones worth seeing.
|
|
435
|
+
#
|
|
436
|
+
# The `deeptutor.access` logger gets its own INFO stdout handler rather than
|
|
437
|
+
# leaning on the root handlers: the root console handler runs at the global log
|
|
438
|
+
# level (WARNING by default), which would swallow these INFO access lines.
|
|
439
|
+
# propagate=False keeps them from also printing through root if the global
|
|
440
|
+
# level is ever lowered to INFO/DEBUG.
|
|
441
|
+
_access_logger = logging.getLogger("deeptutor.access")
|
|
442
|
+
if not any(getattr(h, "_deeptutor_access_handler", False) for h in _access_logger.handlers):
|
|
443
|
+
_access_handler = logging.StreamHandler(sys.stdout)
|
|
444
|
+
_access_handler.setLevel(logging.INFO)
|
|
445
|
+
_access_handler.setFormatter(logging.Formatter("%(message)s"))
|
|
446
|
+
_access_handler._deeptutor_access_handler = True # type: ignore[attr-defined]
|
|
447
|
+
_access_logger.addHandler(_access_handler)
|
|
448
|
+
_access_logger.setLevel(logging.INFO)
|
|
449
|
+
_access_logger.propagate = False
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@app.middleware("http")
|
|
453
|
+
async def selective_access_log(request, call_next):
|
|
454
|
+
response = await call_next(request)
|
|
455
|
+
# An expired app login must not strand provider credentials in a callback URL.
|
|
456
|
+
# Authentication still runs normally; only its failure presentation changes.
|
|
457
|
+
if (
|
|
458
|
+
request.url.path == "/api/video-learning/invidious/account/callback"
|
|
459
|
+
and response.status_code in {401, 403}
|
|
460
|
+
):
|
|
461
|
+
response = RedirectResponse(
|
|
462
|
+
"/reading?account=authorization_login_required",
|
|
463
|
+
status_code=303,
|
|
464
|
+
headers={"Cache-Control": "no-store", "Referrer-Policy": "no-referrer"},
|
|
465
|
+
)
|
|
466
|
+
if response.status_code != 200:
|
|
467
|
+
_access_logger.info(
|
|
468
|
+
'%s - "%s %s HTTP/%s" %d',
|
|
469
|
+
request.client.host if request.client else "-",
|
|
470
|
+
request.method,
|
|
471
|
+
request.url.path,
|
|
472
|
+
request.scope.get("http_version", "1.1"),
|
|
473
|
+
response.status_code,
|
|
474
|
+
)
|
|
475
|
+
return response
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
_cors_settings = _build_cors_settings()
|
|
479
|
+
logger.info(
|
|
480
|
+
"CORS configured: mode=%s allow_origins=%s allow_origin_regex=%s",
|
|
481
|
+
_cors_settings["mode"],
|
|
482
|
+
_cors_settings["allow_origins"],
|
|
483
|
+
_cors_settings["allow_origin_regex"],
|
|
484
|
+
)
|
|
485
|
+
app.add_middleware(
|
|
486
|
+
CORSMiddleware,
|
|
487
|
+
allow_origins=_cors_settings["allow_origins"],
|
|
488
|
+
allow_origin_regex=_cors_settings["allow_origin_regex"],
|
|
489
|
+
allow_credentials=True,
|
|
490
|
+
allow_methods=["*"],
|
|
491
|
+
allow_headers=["*"],
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
# Initialize user directories on startup
|
|
495
|
+
try:
|
|
496
|
+
from deeptutor.services.setup import init_user_directories
|
|
497
|
+
|
|
498
|
+
init_user_directories()
|
|
499
|
+
except Exception:
|
|
500
|
+
# Fallback: just create the main directory if it doesn't exist
|
|
501
|
+
user_dir = get_path_service().get_public_outputs_root()
|
|
502
|
+
if not user_dir.exists():
|
|
503
|
+
user_dir.mkdir(parents=True)
|
|
504
|
+
|
|
505
|
+
# Runtime settings must be initialized before a router module is imported.
|
|
506
|
+
# The native profile defers unrelated feature schemas until their first request.
|
|
507
|
+
import os # noqa: E402
|
|
508
|
+
|
|
509
|
+
from deeptutor.api.router_loader import RouterLoader # noqa: E402
|
|
510
|
+
|
|
511
|
+
_router_loader = RouterLoader(
|
|
512
|
+
app, lazy=os.environ.get("DEEPTUTOR_CHAT_ENGINE") == "typescript-native"
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
from deeptutor.api.module_routes import mount_module_routes
|
|
516
|
+
|
|
517
|
+
mount_module_routes(app, _router_loader)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@app.get("/")
|
|
521
|
+
async def root():
|
|
522
|
+
return {"message": "Welcome to DeepTutor API"}
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@app.get("/health/live")
|
|
526
|
+
async def health_live():
|
|
527
|
+
return {"status": "alive"}
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
@app.get("/health", include_in_schema=False)
|
|
531
|
+
@app.get("/health/ready")
|
|
532
|
+
async def health_ready(request: Request):
|
|
533
|
+
if not bool(getattr(request.app.state, "ready", False)):
|
|
534
|
+
return JSONResponse(status_code=503, content={"status": "not_ready"})
|
|
535
|
+
container = getattr(request.app.state, "application_container", None)
|
|
536
|
+
if container is None or not await container.coordinator.health():
|
|
537
|
+
return JSONResponse(
|
|
538
|
+
status_code=503,
|
|
539
|
+
content={"status": "not_ready", "reason": "coordination_unavailable"},
|
|
540
|
+
)
|
|
541
|
+
return {"status": "ready"}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
if __name__ == "__main__":
|
|
545
|
+
from deeptutor.api.run_server import main as run_server_main
|
|
546
|
+
|
|
547
|
+
run_server_main()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Assemble installed modules without changing their authentication policies."""
|
|
2
|
+
|
|
3
|
+
from fastapi import Depends, FastAPI
|
|
4
|
+
|
|
5
|
+
from deeptutor.api.router_loader import RouterLoader
|
|
6
|
+
from deeptutor.modules.catalog import manifests, module_enabled
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def mount_module_routes(app: FastAPI, loader: RouterLoader) -> None:
|
|
10
|
+
from deeptutor.api.routers.auth import require_admin, require_auth, require_learning_surface
|
|
11
|
+
|
|
12
|
+
policies = {
|
|
13
|
+
"public": [],
|
|
14
|
+
"authenticated": [Depends(require_auth)],
|
|
15
|
+
"learning": [Depends(require_learning_surface)],
|
|
16
|
+
"admin": [Depends(require_admin)],
|
|
17
|
+
}
|
|
18
|
+
routes = [
|
|
19
|
+
route
|
|
20
|
+
for name, module in manifests().items()
|
|
21
|
+
if module_enabled(name)
|
|
22
|
+
for route in module["routes"]
|
|
23
|
+
]
|
|
24
|
+
unavailable = tuple(
|
|
25
|
+
prefix
|
|
26
|
+
for name, row in manifests().items()
|
|
27
|
+
if not module_enabled(name)
|
|
28
|
+
for prefix in row.get("http_paths", [])
|
|
29
|
+
)
|
|
30
|
+
for route in sorted(routes, key=lambda row: row["order"]):
|
|
31
|
+
loader.include(
|
|
32
|
+
route["reference"],
|
|
33
|
+
**route["options"],
|
|
34
|
+
exclude_paths=unavailable,
|
|
35
|
+
dependencies=policies[route["auth"]],
|
|
36
|
+
)
|
|
37
|
+
loader.include(
|
|
38
|
+
"deeptutor.api.routers.modules:router",
|
|
39
|
+
prefix="/api/modules",
|
|
40
|
+
tags=["modules"],
|
|
41
|
+
dependencies=[Depends(require_auth)],
|
|
42
|
+
)
|