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.
Files changed (48) hide show
  1. deeptutor/api/__init__.py +0 -0
  2. deeptutor/api/contracts/__init__.py +21 -0
  3. deeptutor/api/contracts/export.py +110 -0
  4. deeptutor/api/contracts/turn_protocol.py +313 -0
  5. deeptutor/api/main.py +547 -0
  6. deeptutor/api/module_routes.py +42 -0
  7. deeptutor/api/router_index.json +5752 -0
  8. deeptutor/api/router_loader.py +139 -0
  9. deeptutor/api/routers/__init__.py +1 -0
  10. deeptutor/api/routers/agent_config.py +59 -0
  11. deeptutor/api/routers/attachments.py +79 -0
  12. deeptutor/api/routers/auth.py +1937 -0
  13. deeptutor/api/routers/capabilities.py +44 -0
  14. deeptutor/api/routers/capabilities_settings.py +38 -0
  15. deeptutor/api/routers/dashboard.py +292 -0
  16. deeptutor/api/routers/file_library.py +169 -0
  17. deeptutor/api/routers/file_preview.py +205 -0
  18. deeptutor/api/routers/imports.py +146 -0
  19. deeptutor/api/routers/learning_journal.py +18 -0
  20. deeptutor/api/routers/mcp_settings.py +133 -0
  21. deeptutor/api/routers/modules.py +12 -0
  22. deeptutor/api/routers/multi_user.py +943 -0
  23. deeptutor/api/routers/outputs.py +97 -0
  24. deeptutor/api/routers/personas.py +154 -0
  25. deeptutor/api/routers/plugins.py +416 -0
  26. deeptutor/api/routers/quiz_judge.py +464 -0
  27. deeptutor/api/routers/sessions.py +801 -0
  28. deeptutor/api/routers/settings.py +2503 -0
  29. deeptutor/api/routers/skills.py +315 -0
  30. deeptutor/api/routers/space_cli_apps.py +222 -0
  31. deeptutor/api/routers/space_mcp.py +460 -0
  32. deeptutor/api/routers/subagents.py +306 -0
  33. deeptutor/api/routers/system.py +633 -0
  34. deeptutor/api/routers/task_board.py +139 -0
  35. deeptutor/api/routers/tools.py +268 -0
  36. deeptutor/api/routers/unified_ws.py +385 -0
  37. deeptutor/api/routers/workspace.py +371 -0
  38. deeptutor/api/run_server.py +99 -0
  39. deeptutor/api/security_guard.py +91 -0
  40. deeptutor/api/utils/http_headers.py +27 -0
  41. deeptutor/api/utils/progress_broadcaster.py +73 -0
  42. deeptutor/api/utils/task_id_manager.py +120 -0
  43. deeptutor/api/utils/task_log_stream.py +362 -0
  44. deeptutor/api/utils/tool_options.py +133 -0
  45. deeptutor_server-1.7.0.dist-info/METADATA +14 -0
  46. deeptutor_server-1.7.0.dist-info/RECORD +48 -0
  47. deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
  48. 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
+ )