Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

454 lines
15 KiB
Python

from contextlib import asynccontextmanager
import logging
import sys
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from deeptutor.logging import configure_logging
from deeptutor.services.config import (
ensure_runtime_settings_files,
export_runtime_settings_to_env,
load_auth_settings,
load_system_settings,
)
from deeptutor.services.config.origins import normalize_origins
from deeptutor.services.path_service import get_path_service
ensure_runtime_settings_files()
export_runtime_settings_to_env(overwrite=True)
configure_logging()
logger = logging.getLogger(__name__)
class _SuppressWsNoise(logging.Filter):
"""Suppress noisy uvicorn logs for WebSocket connection churn."""
_SUPPRESSED = ("connection open", "connection closed")
def filter(self, record: logging.LogRecord) -> bool:
msg = record.getMessage()
return not any(f in msg for f in self._SUPPRESSED)
logging.getLogger("uvicorn.error").addFilter(_SuppressWsNoise())
CONFIG_DRIFT_ERROR_TEMPLATE = (
"Configuration Drift Detected: Capability tool references {drift} are not "
"registered in the runtime tool registry. Register the missing tools or "
"remove the stale tool names from the capability manifests."
)
class SafeOutputStaticFiles(StaticFiles):
"""Static file mount that only exposes explicitly whitelisted artifacts."""
def __init__(self, *args, path_service, **kwargs):
super().__init__(*args, **kwargs)
self._path_service = path_service
async def get_response(self, path: str, scope):
if not self._path_service.is_public_output_path(path):
raise HTTPException(status_code=404, detail="Output not found")
return await super().get_response(path, scope)
def validate_tool_consistency():
"""
Validate that capability manifests only reference tools that are actually
registered in the runtime ``ToolRegistry``.
"""
try:
from deeptutor.runtime.registry.capability_registry import get_capability_registry
from deeptutor.runtime.registry.tool_registry import get_tool_registry
capability_registry = get_capability_registry()
tool_registry = get_tool_registry()
available_tools = set(tool_registry.list_tools())
referenced_tools = set()
for manifest in capability_registry.get_manifests():
referenced_tools.update(manifest.get("tools_used", []) or [])
drift = referenced_tools - available_tools
if drift:
raise RuntimeError(CONFIG_DRIFT_ERROR_TEMPLATE.format(drift=drift))
except RuntimeError:
logger.exception("Configuration validation failed")
raise
except Exception:
logger.exception("Failed to load configuration for validation")
raise
def _build_cors_settings() -> dict[str, object]:
"""Build CORS settings for both localhost and remote Docker deployments."""
system_settings = load_system_settings()
auth_settings = load_auth_settings()
frontend_port = str(system_settings["frontend_port"])
extra_origins = normalize_origins(
[system_settings["cors_origin"], system_settings["cors_origins"]]
)
origins = [
f"http://localhost:{frontend_port}",
f"http://127.0.0.1:{frontend_port}",
"http://localhost:3000",
"http://127.0.0.1:3000",
]
for origin in extra_origins:
if origin not in origins:
origins.append(origin)
# Auth is disabled by default. In that local/single-user mode, mirror the
# pre-v1.3.8 behavior and allow remote Docker/LAN origins out of the box.
# When auth is enabled, require explicit CORS_ORIGIN(S) for credentialed
# cross-origin requests.
allow_origin_regex = None if auth_settings["enabled"] else r"https?://.*"
mode = "explicit" if auth_settings["enabled"] else "permissive"
return {
"allow_origins": origins,
"allow_origin_regex": allow_origin_regex,
"mode": mode,
}
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Application lifecycle management
Gracefully handle startup and shutdown events, avoid CancelledError
"""
# Execute on startup
logger.info("Application startup")
# Validate configuration consistency
validate_tool_consistency()
# Initialize LLM client early so OPENAI_* env vars are available before
# any downstream provider integrations start.
try:
from deeptutor.services.llm import get_llm_client
llm_client = get_llm_client()
logger.info(f"LLM client initialized: model={llm_client.config.model}")
except Exception as e:
logger.warning(f"Failed to initialize LLM client at startup: {e}")
try:
from deeptutor.events.event_bus import get_event_bus
event_bus = get_event_bus()
await event_bus.start()
logger.info("EventBus started")
except Exception as e:
logger.warning(f"Failed to start EventBus: {e}")
try:
from deeptutor.services.partners import get_partner_manager
await get_partner_manager().auto_start_partners()
except Exception as e:
logger.warning(f"Failed to auto-start partners: {e}")
try:
from deeptutor.services.cron import get_cron_service
await get_cron_service().start()
except Exception as e:
logger.warning(f"Failed to start cron service: {e}")
# Ping PocketBase if configured — logs a warning (not an error) if unreachable
try:
from deeptutor.services.pocketbase_client import ping_pocketbase
await ping_pocketbase()
except Exception as e:
logger.warning(f"PocketBase startup check failed: {e}")
# Migrate any v1 memory files (PROFILE.md / SUMMARY.md) into a
# backup folder so the v2 three-layer subsystem starts clean.
try:
from deeptutor.services.memory import (
migrate_partner_surface_if_needed,
migrate_v1_if_needed,
)
backup = migrate_v1_if_needed()
if backup is not None:
logger.info("v1 memory archived to %s", backup)
# Rename the legacy ``tutorbot`` memory surface (footnote refs, L2
# doc, snapshot/trace dirs, L3 meta keys) to ``partner``.
migrate_partner_surface_if_needed()
except Exception as e:
logger.warning(f"v1 memory migration failed: {e}")
yield
# Execute on shutdown
logger.info("Application shutdown")
# Stop cron scheduler
try:
from deeptutor.services.cron import get_cron_service
await get_cron_service().stop()
except Exception as e:
logger.warning(f"Failed to stop cron service: {e}")
# Stop partners
try:
from deeptutor.services.partners import get_partner_manager
await get_partner_manager().stop_all(preserve_auto_start=True)
logger.info("Partners stopped")
except Exception as e:
logger.warning(f"Failed to stop partners: {e}")
# Stop EventBus
try:
from deeptutor.events.event_bus import get_event_bus
event_bus = get_event_bus()
await event_bus.stop()
logger.info("EventBus stopped")
except Exception as e:
logger.warning(f"Failed to stop EventBus: {e}")
app = FastAPI(
title="DeepTutor API",
version="1.0.0",
lifespan=lifespan,
# Disable automatic trailing slash redirects to prevent protocol downgrade issues
# when deployed behind HTTPS reverse proxies (e.g., nginx).
# Without this, FastAPI's 307 redirects may change HTTPS to HTTP.
# See: https://github.com/HKUDS/DeepTutor/issues/112
redirect_slashes=False,
)
# Access logging is funneled through this one middleware. uvicorn's own
# per-request access log is disabled on every launch path (run_server.py via
# access_log=False; the launcher and Docker via `--no-access-log`), so routine
# 200s — the chatty frontend polling of /settings, /tools, /knowledge/list,
# etc. — never reach the logs. Only non-200s are surfaced, since those are the
# ones worth seeing.
#
# The `deeptutor.access` logger gets its own INFO stdout handler rather than
# leaning on the root handlers: the root console handler runs at the global log
# level (WARNING by default), which would swallow these INFO access lines.
# propagate=False keeps them from also printing through root if the global
# level is ever lowered to INFO/DEBUG.
_access_logger = logging.getLogger("deeptutor.access")
if not any(getattr(h, "_deeptutor_access_handler", False) for h in _access_logger.handlers):
_access_handler = logging.StreamHandler(sys.stdout)
_access_handler.setLevel(logging.INFO)
_access_handler.setFormatter(logging.Formatter("%(message)s"))
_access_handler._deeptutor_access_handler = True # type: ignore[attr-defined]
_access_logger.addHandler(_access_handler)
_access_logger.setLevel(logging.INFO)
_access_logger.propagate = False
@app.middleware("http")
async def selective_access_log(request, call_next):
response = await call_next(request)
if response.status_code != 200:
_access_logger.info(
'%s - "%s %s HTTP/%s" %d',
request.client.host if request.client else "-",
request.method,
request.url.path,
request.scope.get("http_version", "1.1"),
response.status_code,
)
return response
_cors_settings = _build_cors_settings()
logger.info(
"CORS configured: mode=%s allow_origins=%s allow_origin_regex=%s",
_cors_settings["mode"],
_cors_settings["allow_origins"],
_cors_settings["allow_origin_regex"],
)
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_settings["allow_origins"],
allow_origin_regex=_cors_settings["allow_origin_regex"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount a filtered view over user outputs.
# Only whitelisted artifact paths are readable through the static handler.
path_service = get_path_service()
user_dir = path_service.get_public_outputs_root()
# Initialize user directories on startup
try:
from deeptutor.services.setup import init_user_directories
init_user_directories()
except Exception:
# Fallback: just create the main directory if it doesn't exist
if not user_dir.exists():
user_dir.mkdir(parents=True)
app.mount(
"/api/outputs",
SafeOutputStaticFiles(directory=str(user_dir), path_service=path_service),
name="outputs",
)
# Import routers only after runtime settings are initialized.
# Some router modules load YAML settings at import time.
from deeptutor.api.routers import (
agent_config,
attachments,
auth,
book,
capabilities_settings,
chat,
co_writer,
dashboard,
imports,
knowledge,
mastery_path,
mcp_settings,
memory,
notebook,
partners,
personas,
plugins_api,
question,
question_notebook,
quiz_judge,
sessions,
settings,
skills,
subagents,
system,
unified_ws,
voice,
)
from deeptutor.api.routers import (
tools as tools_router,
)
from deeptutor.multi_user.router import router as multi_user_router # noqa: E402
# Auth router is public — login/logout/register/status require no token
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
# All other routers require a valid session when AUTH_ENABLED=true.
# require_auth is a no-op when AUTH_ENABLED=false, so this is safe for local use.
from deeptutor.api.routers.auth import require_admin, require_auth # noqa: E402
_auth = [Depends(require_auth)]
# Partner data is anchored at the admin workspace (data/partners) and shared
# process-wide, so management is admin-gated in multi-user deployments
# (single-user local runs are implicitly admin — no behaviour change there).
_admin = [Depends(require_admin)]
app.include_router(
multi_user_router,
prefix="/api/v1/multi-user",
tags=["multi-user"],
dependencies=_auth,
)
app.include_router(chat.router, prefix="/api/v1", tags=["chat"], dependencies=_auth)
app.include_router(
question.router, prefix="/api/v1/question", tags=["question"], dependencies=_auth
)
app.include_router(
knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"], dependencies=_auth
)
app.include_router(imports.router, prefix="/api/v1/imports", tags=["imports"], dependencies=_auth)
app.include_router(
dashboard.router, prefix="/api/v1/dashboard", tags=["dashboard"], dependencies=_auth
)
app.include_router(
mastery_path.router,
prefix="/api/v1/learning",
tags=["mastery-path"],
dependencies=_auth,
)
app.include_router(
co_writer.router, prefix="/api/v1/co_writer", tags=["co_writer"], dependencies=_auth
)
app.include_router(
notebook.router, prefix="/api/v1/notebook", tags=["notebook"], dependencies=_auth
)
app.include_router(book.router, prefix="/api/v1/book", tags=["book"], dependencies=_auth)
app.include_router(memory.router, prefix="/api/v1/memory", tags=["memory"], dependencies=_auth)
app.include_router(
capabilities_settings.router,
prefix="/api/v1/capabilities",
tags=["capabilities"],
dependencies=_auth,
)
app.include_router(
sessions.router, prefix="/api/v1/sessions", tags=["sessions"], dependencies=_auth
)
app.include_router(
question_notebook.router,
prefix="/api/v1/question-notebook",
tags=["question-notebook"],
dependencies=_auth,
)
app.include_router(
settings.router, prefix="/api/v1/settings", tags=["settings"], dependencies=_auth
)
app.include_router(
mcp_settings.router,
prefix="/api/v1/settings/mcp",
tags=["mcp-settings"],
dependencies=_auth,
)
app.include_router(skills.router, prefix="/api/v1/skills", tags=["skills"], dependencies=_auth)
app.include_router(
subagents.router, prefix="/api/v1/subagents", tags=["subagents"], dependencies=_auth
)
app.include_router(
personas.router, prefix="/api/v1/personas", tags=["personas"], dependencies=_auth
)
app.include_router(tools_router.router, prefix="/api/v1/tools", tags=["tools"], dependencies=_auth)
app.include_router(system.router, prefix="/api/v1/system", tags=["system"], dependencies=_auth)
app.include_router(voice.router, prefix="/api/v1/voice", tags=["voice"], dependencies=_auth)
app.include_router(
plugins_api.router, prefix="/api/v1/plugins", tags=["plugins"], dependencies=_auth
)
app.include_router(
agent_config.router, prefix="/api/v1/agent-config", tags=["agent-config"], dependencies=_auth
)
app.include_router(
partners.router, prefix="/api/v1/partners", tags=["partners"], dependencies=_admin
)
app.include_router(
attachments.router,
prefix="/api/attachments",
tags=["attachments"],
dependencies=_auth,
)
# Unified WebSocket endpoint — auth is checked inside the handler (WebSockets
# cannot use FastAPI dependencies in the standard way)
app.include_router(unified_ws.router, prefix="/api/v1", tags=["unified-ws"])
# Quiz AI-judge WebSocket — same caveat as unified_ws above; auth is checked
# inside the handler so the WS upgrade isn't rejected by an HTTP-style dep.
app.include_router(quiz_judge.router, prefix="/api/v1", tags=["quiz-judge"])
@app.get("/")
async def root():
return {"message": "Welcome to DeepTutor API"}
if __name__ == "__main__":
from deeptutor.api.run_server import main as run_server_main
run_server_main()