项目文件夹

文件
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 行
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()