chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:00:43 +08:00
commit e4dcfc49aa
1668 changed files with 324490 additions and 0 deletions
View File
+453
View File
@@ -0,0 +1,453 @@
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()
+1
View File
@@ -0,0 +1 @@
# Init file for routers
@@ -0,0 +1,176 @@
"""Channel-schema introspection — bridges Pydantic channel configs to the Web UI.
Used by ``GET /api/v1/partners/channels/schema`` so the front-end can render
generic forms for ANY channel (built-in or plugin) without hard-coding fields.
Why live here vs inside ``deeptutor.partners.channels``?
* This is an API-shaping concern (JSON Schema flattening, secret-field
detection) — keeping it next to the route avoids polluting the runtime
channel package with HTTP-specific helpers.
"""
from __future__ import annotations
import inspect
from typing import Any
from pydantic import BaseModel
from deeptutor.services.partners.manager import _is_secret_field
def resolve_config_model(channel_cls: type) -> type[BaseModel] | None:
"""Find the Pydantic config model paired with ``channel_cls``.
Convention every built-in channel follows: ``XxxChannel`` lives in the
same module as ``XxxConfig`` (e.g. ``TelegramChannel`` ↔ ``TelegramConfig``).
Falls back to "any ``*Config`` BaseModel in the module".
"""
module = inspect.getmodule(channel_cls)
if module is None:
return None
expected = channel_cls.__name__.replace("Channel", "") + "Config"
candidate = getattr(module, expected, None)
if isinstance(candidate, type) and issubclass(candidate, BaseModel):
return candidate
for _, obj in inspect.getmembers(module):
if (
isinstance(obj, type)
and obj is not BaseModel
and issubclass(obj, BaseModel)
and obj.__name__.endswith("Config")
):
return obj
return None
def inline_refs(schema: dict[str, Any]) -> dict[str, Any]:
"""Flatten Pydantic's ``$defs`` / ``$ref`` so the front-end doesn't need a resolver.
Nested model fields (e.g. ``slack.dm: SlackDMConfig``) become inline
``type: object`` subtrees with their own ``properties``.
"""
defs: dict[str, Any] = dict(schema.get("$defs", {}))
def _walk(node: Any) -> Any:
if isinstance(node, dict):
ref = node.get("$ref")
if isinstance(ref, str) and ref.startswith("#/$defs/"):
ref_name = ref.rsplit("/", 1)[-1]
resolved = defs.get(ref_name, {})
merged = {**resolved}
# Allow per-field overrides (description, default) from the ref site.
for k, v in node.items():
if k != "$ref":
merged[k] = v
return _walk(merged)
return {k: _walk(v) for k, v in node.items()}
if isinstance(node, list):
return [_walk(item) for item in node]
return node
out = _walk(schema)
if isinstance(out, dict):
out.pop("$defs", None)
return out
def _schema_accepts_string(prop_schema: dict[str, Any]) -> bool:
"""True iff the JSON-Schema fragment can hold a string value.
Used to filter out booleans/integers/arrays whose name happens to contain
a secret-looking substring (e.g. ``user_token_read_only: bool``).
"""
t = prop_schema.get("type")
if t == "string":
return True
if isinstance(t, list) and "string" in t:
return True
for variant in prop_schema.get("anyOf", []):
if isinstance(variant, dict) and variant.get("type") == "string":
return True
return False
def collect_secret_fields(schema: dict[str, Any], prefix: str = "") -> list[str]:
"""Return dot-paths for every string-typed property whose name hints at a secret.
e.g. ``["token"]`` for telegram, ``["imap_password", "smtp_password"]``
for email, ``["bot_token", "app_token"]`` for slack. A field like
``user_token_read_only: bool`` is intentionally skipped.
"""
paths: list[str] = []
properties = schema.get("properties") if isinstance(schema, dict) else None
if not isinstance(properties, dict):
return paths
for prop_name, prop_schema in properties.items():
if not isinstance(prop_schema, dict):
continue
full = f"{prefix}{prop_name}" if not prefix else f"{prefix}.{prop_name}"
if _is_secret_field(prop_name) and _schema_accepts_string(prop_schema):
paths.append(full)
if prop_schema.get("type") == "object":
paths.extend(collect_secret_fields(prop_schema, prefix=full))
return paths
def channel_schema_payload(channel_cls: type) -> dict[str, Any] | None:
"""Build the per-channel schema payload, or ``None`` if no config model found."""
model = resolve_config_model(channel_cls)
if model is None:
return None
# by_alias=False → property names match Python field names (snake_case),
# which is exactly the shape we persist in ``config.yaml`` and what every
# channel's ``__init__`` expects when ``model_validate(dict)`` is called.
# The pydantic Base config has populate_by_name=True so the runtime still
# accepts both forms; we standardise on snake_case for the wire schema.
raw = model.model_json_schema(by_alias=False)
flat = inline_refs(raw)
secret_fields = collect_secret_fields(flat)
try:
default_config = model().model_dump(mode="json", by_alias=False)
except Exception:
default_config = {}
return {
"name": getattr(channel_cls, "name", channel_cls.__name__),
"display_name": getattr(channel_cls, "display_name", channel_cls.__name__),
"default_config": default_config,
"secret_fields": secret_fields,
"json_schema": flat,
}
def all_channel_schemas() -> dict[str, dict[str, Any]]:
"""Build the schema dict for every discovered channel (built-in + plugins).
Channels whose module failed to import (missing optional dependency)
still appear, marked ``available: False`` with the import error — the UI
shows them grayed out instead of silently dropping them.
"""
from deeptutor.partners.channels.registry import discover_all_with_errors
channels, errors = discover_all_with_errors()
out: dict[str, dict[str, Any]] = {}
for name, cls in channels.items():
payload = channel_schema_payload(cls)
if payload is not None:
payload["available"] = True
out[name] = payload
for name, reason in errors.items():
out[name] = {
"name": name,
"display_name": name.title(),
"available": False,
"unavailable_reason": reason,
"default_config": {"enabled": False},
"secret_fields": [],
"json_schema": None,
}
return out
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python
"""
Agent Configuration API - Provides agent metadata for data-driven UI.
"""
from fastapi import APIRouter
router = APIRouter()
# Agent registry - single source of truth for agent UI metadata
AGENT_REGISTRY = {
"solve": {
"icon": "HelpCircle",
"color": "blue",
"label_key": "Problem Solved",
},
"question": {
"icon": "FileText",
"color": "purple",
"label_key": "Question Generated",
},
"research": {
"icon": "Search",
"color": "emerald",
"label_key": "Research Report",
},
"co_writer": {
"icon": "PenTool",
"color": "amber",
"label_key": "Co-Writer",
},
}
@router.get("/agents")
async def get_agent_config():
"""
Get agent UI configuration.
Returns:
Dict mapping agent type to UI metadata (icon, color, label_key)
"""
return AGENT_REGISTRY
@router.get("/agents/{agent_type}")
async def get_single_agent_config(agent_type: str):
"""
Get UI configuration for a specific agent.
Args:
agent_type: Agent type (solve, question, research, etc.)
Returns:
Agent UI metadata or 404 if not found
"""
if agent_type in AGENT_REGISTRY:
return AGENT_REGISTRY[agent_type]
return {"error": f"Agent type '{agent_type}' not found"}
+91
View File
@@ -0,0 +1,91 @@
"""HTTP endpoint for chat attachment downloads / previews.
The chat turn runtime persists every uploaded attachment to the
:class:`~deeptutor.services.storage.AttachmentStore` and records the public
URL on the message. The frontend preview drawer loads files via this
router, which only serves paths the store hands back — every component is
sanitised to defend against directory traversal.
URL shape::
GET /api/attachments/{session_id}/{attachment_id}/{filename}
The session id functions as the ACL boundary, mirroring how the rest of
the app treats sessions today (single-tenant, session ownership is local
trust). Once multi-user auth lands we should swap this for signed URLs.
"""
from __future__ import annotations
import logging
import mimetypes
from urllib.parse import quote
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from deeptutor.services.storage import (
LocalDiskAttachmentStore,
get_attachment_store,
)
logger = logging.getLogger(__name__)
router = APIRouter()
def _content_disposition(filename: str, *, disposition: str = "inline") -> str:
"""Build a Content-Disposition header that survives non-ASCII filenames.
HTTP/1.1 headers are latin-1, so dropping a Chinese / accented filename
straight into ``filename="..."`` blows up with UnicodeEncodeError. RFC
6266 / RFC 5987 cover this: emit ``filename*=UTF-8''<percent-encoded>``
plus an ASCII fallback on ``filename=`` for legacy clients.
"""
ascii_fallback = filename.encode("ascii", errors="replace").decode("ascii")
# Quotes / backslashes break the simple-quoted-string form; collapse them.
ascii_fallback = ascii_fallback.replace('"', "_").replace("\\", "_")
encoded = quote(filename, safe="")
return f"{disposition}; filename=\"{ascii_fallback}\"; filename*=UTF-8''{encoded}"
@router.get("/{session_id}/{attachment_id}/{filename:path}")
async def get_attachment(
session_id: str,
attachment_id: str,
filename: str,
):
"""Serve a previously uploaded chat attachment.
Responds with ``Content-Disposition: inline`` so browsers preview PDFs
and images directly in an ``<iframe>`` / ``<img>``. For unknown types
the browser still falls back to download, which is fine for the
drawer's "Download" button path.
"""
store = get_attachment_store()
if not isinstance(store, LocalDiskAttachmentStore):
# Future remote backends should issue a redirect to the signed URL
# here. Local-disk is the only backend today, so this branch just
# guards against an unexpected configuration.
raise HTTPException(status_code=501, detail="Attachment backend not servable")
target = store.resolve_path(
session_id=session_id,
attachment_id=attachment_id,
filename=filename,
)
if target is None:
raise HTTPException(status_code=404, detail="Attachment not found")
media_type, _ = mimetypes.guess_type(target.name)
if not media_type:
media_type = "application/octet-stream"
# ``inline`` lets the browser preview the file when possible while still
# honouring the suggested filename for the drawer's download action.
headers = {
"Content-Disposition": _content_disposition(target.name),
# User-uploaded data; do not let intermediaries cache it.
"Cache-Control": "private, max-age=0, must-revalidate",
}
return FileResponse(path=str(target), media_type=media_type, headers=headers)
+831
View File
@@ -0,0 +1,831 @@
"""Auth router — login, logout, status, registration, profile, and user-management endpoints."""
from contextvars import Token as _CtxToken
import logging
import re
from fastapi import (
APIRouter,
Cookie,
Depends,
File,
Header,
HTTPException,
Response,
UploadFile,
WebSocket,
status,
)
from fastapi.responses import FileResponse
from pydantic import BaseModel, field_validator
from deeptutor.services.config import load_auth_settings
# SameSite=None lets the cookie work when the browser accesses the frontend via
# 127.0.0.1 and the backend via localhost (different origins on the same machine).
# Browsers require Secure=True for SameSite=None, but that needs HTTPS — so in
# local dev we fall back to SameSite=Lax and tell users to use localhost:// URLs.
_SECURE = bool(load_auth_settings()["cookie_secure"])
_SAMESITE = "none" if _SECURE else "lax"
from deeptutor.multi_user.context import set_current_user, user_from_token_payload
from deeptutor.multi_user.paths import local_admin_user
from deeptutor.services.auth import (
AUTH_ENABLED,
POCKETBASE_ENABLED,
TOKEN_EXPIRE_HOURS,
TokenPayload,
add_user,
authenticate,
authenticate_pb,
create_token,
decode_token,
delete_user,
get_user_info,
is_first_user,
list_users,
register_pb,
set_avatar,
set_role,
)
logger = logging.getLogger(__name__)
router = APIRouter()
_COOKIE_NAME = "dt_token"
_COOKIE_MAX_AGE = TOKEN_EXPIRE_HOURS * 3600
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class LoginRequest(BaseModel):
"""Payload for the POST /login endpoint."""
username: str
password: str
class RegisterRequest(BaseModel):
"""Payload for the POST /register endpoint."""
username: str
password: str
@field_validator("username")
@classmethod
def username_valid(cls, v: str) -> str:
import re
v = v.strip()
if not v:
raise ValueError("Email cannot be empty")
# Accept standard email addresses (used by PocketBase mode) or plain
# usernames (used by the built-in SQLite/JSON auth mode).
email_re = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
plain_re = re.compile(r"^[A-Za-z0-9_\-.]{3,64}$")
if not email_re.match(v) and not plain_re.match(v):
raise ValueError("Enter a valid email address")
return v
@field_validator("password")
@classmethod
def password_valid(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
return v
class SetRoleRequest(BaseModel):
"""Payload for the PUT /users/{username}/role endpoint."""
role: str
@field_validator("role")
@classmethod
def role_valid(cls, v: str) -> str:
if v not in ("admin", "user"):
raise ValueError("Role must be 'admin' or 'user'")
return v
class AuthStatusResponse(BaseModel):
"""Response body for the GET /status endpoint."""
enabled: bool
authenticated: bool
user_id: str | None = None
username: str | None = None
role: str | None = None
is_admin: bool = False
avatar: str = ""
class UserInfo(BaseModel):
"""Single user record returned by the GET /users and /profile endpoints."""
id: str = ""
username: str
role: str
created_at: str
disabled: bool = False
avatar: str = ""
# Markers settable through PUT /profile. Image markers ("img:<version>") are
# managed exclusively by the upload endpoint so users cannot point their
# avatar at a file that was never validated.
_ICON_MARKER_RE = re.compile(r"^icon:[a-z0-9-]{1,32}:[a-z0-9-]{1,32}$")
# User ids are generated as "u_<uuid hex>" (plus the "local-admin" /
# "env-admin" sentinels); reject anything else before it reaches the
# filesystem layer.
_USER_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
class UpdateProfileRequest(BaseModel):
"""Payload for the PUT /profile endpoint."""
avatar: str
@field_validator("avatar")
@classmethod
def avatar_valid(cls, v: str) -> str:
v = v.strip()
if v and not _ICON_MARKER_RE.match(v):
raise ValueError("Avatar must be empty or 'icon:<name>:<color>'")
return v
# ---------------------------------------------------------------------------
# Shared helper — extract token from cookie or Bearer header
# ---------------------------------------------------------------------------
def _bearer_token_from_header(authorization: str | None) -> str | None:
"""Parse ``Authorization: Bearer <token>`` without using ``HTTPBearer``.
``HTTPBearer`` is a class-based dependency whose ``__call__`` is annotated
``request: Request``. FastAPI doesn't inject a Request into WebSocket
dependency resolution, which makes ``HTTPBearer`` raise ``TypeError`` the
moment a router with this dep mounts a WS endpoint. Doing the parse by
hand keeps ``require_auth`` HTTP/WS-symmetric.
"""
if not authorization:
return None
parts = authorization.split(None, 1)
if len(parts) == 2 and parts[0].lower() == "bearer":
token = parts[1].strip()
return token or None
return None
def _extract_token(authorization: str | None, dt_token: str | None) -> str | None:
return _bearer_token_from_header(authorization) or dt_token
# ---------------------------------------------------------------------------
# Dependencies — reusable auth guards for other routers
# ---------------------------------------------------------------------------
def _install_current_user(payload: TokenPayload | None) -> _CtxToken:
"""Install the request-local current-user ContextVar from an auth result.
Single point of truth for ``payload → CurrentUser`` so HTTP and WebSocket
entry points produce identical user objects. ``payload is None`` means
"no JWT was required" (AUTH_ENABLED=false) and resolves to the local
admin user; a non-None payload resolves through ``user_from_token_payload``.
Returns the ContextVar reset token. HTTP callers ignore it (the request
ends with the task, so the var is GC'd with the task context). WebSocket
callers keep it and call ``reset_current_user`` in their ``finally`` block,
because a WS connection outlives the dependency-resolution task.
⚠ Invariant: every authenticated entry point MUST call this before the
handler runs. Skipping it leaves ``get_current_path_service()`` falling
back to the admin workspace — the silent-routing root cause of #481.
"""
user = local_admin_user() if payload is None else user_from_token_payload(payload)
return set_current_user(user)
async def require_auth(
authorization: str | None = Header(default=None, alias="Authorization"),
dt_token: str | None = Cookie(default=None),
) -> TokenPayload | None:
"""
FastAPI dependency that enforces authentication when AUTH_ENABLED=true.
Accepts the JWT from either:
- Authorization: Bearer <token> header
- dt_token cookie
``Header`` and ``Cookie`` are kept here in place of ``HTTPBearer`` so the
function stays usable from WebSocket call sites that don't go through
FastAPI's standard HTTP request lifecycle.
Returns the authenticated TokenPayload, or None if auth is disabled.
Raises HTTP 401 if auth is enabled but the token is missing or invalid.
Declared ``async def`` so the ``set_current_user`` call runs in the same
asyncio context as the endpoint. A sync dependency is dispatched via
``anyio.to_thread.run_sync``, which executes the function in a worker
thread under a *copy* of the request context; any ``ContextVar.set``
inside that thread is discarded when the thread returns, leaving the
endpoint to read the unset default. That regression was the root cause
of #481.
"""
if not AUTH_ENABLED:
_install_current_user(None)
return None
token = _extract_token(authorization, dt_token)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
_install_current_user(payload)
return payload
class _WsAuthFailed:
"""Sentinel: ws_require_auth failed and closed the WebSocket."""
ws_auth_failed: _WsAuthFailed = _WsAuthFailed()
async def ws_require_auth(ws: WebSocket) -> _CtxToken | _WsAuthFailed:
"""Authenticate a WebSocket connection and set the user ContextVar.
Must be called **before** ``ws.accept()`` so the server can reject
unauthenticated upgrades cleanly.
Returns a ContextVar reset token on success, or ``ws_auth_failed``
on failure (the WebSocket is already closed — the caller should
``return`` immediately).
Usage::
user_token = await ws_require_auth(ws)
if user_token is ws_auth_failed:
return
await ws.accept()
try:
...
finally:
reset_current_user(user_token)
"""
if not AUTH_ENABLED:
return _install_current_user(None)
token = ws.query_params.get("token") or ws.cookies.get("dt_token")
payload = decode_token(token) if token else None
if not payload:
await ws.close(code=4001)
return ws_auth_failed
return _install_current_user(payload)
async def require_admin(
payload: TokenPayload | None = Depends(require_auth),
) -> TokenPayload:
"""
FastAPI dependency that requires the caller to be an admin.
Raises HTTP 403 if the authenticated user is not an admin.
When AUTH_ENABLED=false, all requests are treated as admin.
``async def`` mirrors ``require_auth`` so the dependency chain stays on
the event loop and the user ContextVar set by ``require_auth`` is visible
to the endpoint.
"""
if not AUTH_ENABLED:
return _local_admin_token_payload()
if payload is None or payload.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return payload
def _local_admin_token_payload() -> TokenPayload:
"""Synthetic admin payload used when AUTH_ENABLED=false.
Mirrors the local admin identity (LOCAL_ADMIN_USERNAME / LOCAL_ADMIN_ID)
so audit logs and self-reference checks behave the same as in multi-user
mode. Values are kept aligned with ``local_admin_user()`` in
``deeptutor/multi_user/paths.py``.
"""
from deeptutor.multi_user.models import LOCAL_ADMIN_ID, LOCAL_ADMIN_USERNAME
return TokenPayload(
username=LOCAL_ADMIN_USERNAME,
role="admin",
user_id=LOCAL_ADMIN_ID,
)
# ---------------------------------------------------------------------------
# Public endpoints (no auth required)
# ---------------------------------------------------------------------------
@router.get("/status", response_model=AuthStatusResponse)
async def auth_status(
authorization: str | None = Header(default=None, alias="Authorization"),
dt_token: str | None = Cookie(default=None),
) -> AuthStatusResponse:
"""Return whether auth is enabled and whether the current request is authenticated."""
if not AUTH_ENABLED:
return AuthStatusResponse(
enabled=False,
authenticated=True,
user_id="local-admin",
username="local",
role="admin",
is_admin=True,
)
token = _extract_token(authorization, dt_token)
payload = decode_token(token) if token else None
avatar = ""
if payload is not None:
info = get_user_info(payload.username)
if info:
avatar = str(info.get("avatar") or "")
return AuthStatusResponse(
enabled=True,
authenticated=payload is not None,
user_id=payload.user_id if payload else None,
username=payload.username if payload else None,
role=payload.role if payload else None,
is_admin=payload.role == "admin" if payload else False,
avatar=avatar,
)
@router.post("/login")
async def login(body: LoginRequest, response: Response) -> dict:
"""Validate credentials and set a JWT cookie."""
if not AUTH_ENABLED:
return {"ok": True, "message": "Auth is disabled — no login required."}
if POCKETBASE_ENABLED:
# PocketBase mode: email = username field for backwards-compat with the
# existing LoginRequest schema; users can pass their email as "username".
pb_result = authenticate_pb(body.username, body.password)
if not pb_result:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
)
payload, pb_token = pb_result
response.set_cookie(
key=_COOKIE_NAME,
value=pb_token,
httponly=True,
samesite=_SAMESITE,
max_age=_COOKIE_MAX_AGE,
secure=_SECURE,
)
logger.info(f"User '{payload.username}' logged in via PocketBase (role={payload.role!r})")
return {
"ok": True,
"user_id": payload.user_id,
"username": payload.username,
"role": payload.role,
"is_admin": payload.role == "admin",
}
# Standard JWT + bcrypt mode
result = authenticate(body.username, body.password)
if not result:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
token = create_token(result.username, result.role, result.user_id)
response.set_cookie(
key=_COOKIE_NAME,
value=token,
httponly=True,
samesite=_SAMESITE,
max_age=_COOKIE_MAX_AGE,
secure=_SECURE,
)
logger.info(f"User '{result.username}' logged in (role={result.role!r})")
return {
"ok": True,
"user_id": result.user_id,
"username": result.username,
"role": result.role,
"is_admin": result.role == "admin",
}
@router.post("/logout")
async def logout(response: Response) -> dict:
"""Clear the JWT cookie."""
response.delete_cookie(key=_COOKIE_NAME, samesite=_SAMESITE)
return {"ok": True}
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(body: RegisterRequest) -> dict:
"""
Bootstrap-only registration.
Public endpoint that creates the *first* admin account when the user store
is empty. Once an admin exists, this endpoint is closed; further accounts
must be created by an admin via ``POST /api/v1/auth/users``.
Only available when AUTH_ENABLED=true.
"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Auth is disabled — registration is not available.",
)
if POCKETBASE_ENABLED:
# PocketBase deployments are documented as single-user. Keep registration
# closed and require admins to provision users in the PocketBase admin UI.
if not is_first_user():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Self-registration is closed. Ask an administrator to create your account.",
)
result = register_pb(username=body.username, email=body.username, password=body.password)
if not result:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Registration failed — username or email may already be taken.",
)
logger.info(f"First user registered via PocketBase: '{body.username}'")
return {
"ok": True,
"user_id": result.get("id", ""),
"username": body.username,
"role": "user",
"is_first_user": True,
"is_admin": False,
}
# Standard mode — only allowed before the first admin exists.
if not is_first_user():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Self-registration is closed. Ask an administrator to create your account.",
)
existing = {u["username"] for u in list_users()}
if body.username in existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already taken",
)
add_user(body.username, body.password)
user_id = ""
role = "user"
for item in list_users():
if item.get("username") == body.username:
user_id = str(item.get("id") or "")
role = str(item.get("role") or "user")
break
logger.info(f"First user (admin) registered: '{body.username}'")
return {
"ok": True,
"user_id": user_id,
"username": body.username,
"role": role,
"is_first_user": True,
"is_admin": role == "admin",
}
@router.get("/is_first_user")
async def check_is_first_user() -> dict:
"""Return whether the user store is empty (used by the register UI)."""
return {"is_first_user": is_first_user() if AUTH_ENABLED else False}
# ---------------------------------------------------------------------------
# Profile endpoints (any authenticated user, self-service)
# ---------------------------------------------------------------------------
_AVATAR_MAX_BYTES = 1 * 1024 * 1024
_AVATAR_MEDIA_TYPES = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
def _sniff_image(data: bytes) -> str | None:
"""Detect a supported raster image format from its magic bytes.
The uploaded filename and Content-Type are attacker-controlled, so the
stored extension (and the media type served back) is derived from the
bytes alone. SVG is deliberately unsupported — serving user-supplied SVG
is a stored-XSS vector.
"""
if data[:8] == b"\x89PNG\r\n\x1a\n":
return "png"
if data[:3] == b"\xff\xd8\xff":
return "jpg"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "webp"
return None
def _require_profile_identity(payload: TokenPayload | None) -> TokenPayload:
"""Shared guard for the self-service profile endpoints."""
if not AUTH_ENABLED or payload is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Auth is disabled — profiles are not available.",
)
return payload
@router.get("/profile", response_model=UserInfo)
async def get_profile(
payload: TokenPayload | None = Depends(require_auth),
) -> UserInfo:
"""Return the current user's own account info."""
current = _require_profile_identity(payload)
info = get_user_info(current.username)
if info is None:
# PocketBase-backed identities have no local record; fall back to the
# token claims so the profile page still renders.
return UserInfo(
id=current.user_id,
username=current.username,
role=current.role,
created_at="",
)
return UserInfo(**info)
@router.put("/profile")
async def update_profile(
body: UpdateProfileRequest,
payload: TokenPayload | None = Depends(require_auth),
) -> dict:
"""Update the current user's own avatar marker (icon choice or reset).
Only the validated ``icon:<name>:<color>`` form (or empty string) is
accepted here; ``img:`` markers are owned by the upload endpoint.
"""
current = _require_profile_identity(payload)
if not set_avatar(current.username, body.avatar):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
# The marker no longer references an uploaded image, so drop the file.
from deeptutor.multi_user.identity import delete_avatar_file
if current.user_id and _USER_ID_RE.match(current.user_id):
delete_avatar_file(current.user_id)
return {"ok": True, "avatar": body.avatar}
@router.put("/profile/avatar")
async def upload_avatar(
file: UploadFile = File(...),
payload: TokenPayload | None = Depends(require_auth),
) -> dict:
"""Upload an avatar image for the current user.
The client is expected to crop/resize before uploading; the server only
enforces a size cap and validates the format by magic bytes. Not available
in PocketBase mode (those identities have no local user record).
"""
current = _require_profile_identity(payload)
if POCKETBASE_ENABLED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Avatar upload is not available in PocketBase mode.",
)
if not current.user_id or not _USER_ID_RE.match(current.user_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot store an avatar for this account.",
)
info = get_user_info(current.username)
if info is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
data = await file.read(_AVATAR_MAX_BYTES + 1)
if len(data) > _AVATAR_MAX_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="Avatar image is too large (max 1 MB).",
)
ext = _sniff_image(data)
if ext is None:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="Avatar must be a PNG, JPEG or WebP image.",
)
from deeptutor.multi_user.identity import save_avatar_file
# Bump the version embedded in the marker so clients cache-bust the URL.
previous = str(info.get("avatar") or "")
version = 1
if previous.startswith("img:"):
try:
version = int(previous.split(":", 1)[1]) + 1
except ValueError:
version = 1
marker = f"img:{version}"
save_avatar_file(current.user_id, data, ext)
if not set_avatar(current.username, marker):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
logger.info(f"User '{current.username}' uploaded a new avatar ({ext}, {len(data)} bytes)")
return {"ok": True, "avatar": marker}
@router.delete("/profile/avatar")
async def remove_avatar(
payload: TokenPayload | None = Depends(require_auth),
) -> dict:
"""Remove the current user's uploaded avatar image and reset the marker."""
current = _require_profile_identity(payload)
from deeptutor.multi_user.identity import delete_avatar_file
if current.user_id and _USER_ID_RE.match(current.user_id):
delete_avatar_file(current.user_id)
set_avatar(current.username, "")
return {"ok": True, "avatar": ""}
@router.get("/avatar/{user_id}")
async def get_avatar_image(
user_id: str,
_: TokenPayload | None = Depends(require_auth),
) -> FileResponse:
"""Serve a stored avatar image. Any authenticated user may view avatars
(they appear in the admin table and next to the viewer's own profile)."""
if not _USER_ID_RE.match(user_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Avatar not found")
from deeptutor.multi_user.identity import get_avatar_file
target = get_avatar_file(user_id)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Avatar not found")
media_type = _AVATAR_MEDIA_TYPES.get(target.suffix.lstrip("."), "application/octet-stream")
headers = {
# Private user content; the marker version in the URL handles busting.
"Cache-Control": "private, max-age=86400",
"X-Content-Type-Options": "nosniff",
"Content-Disposition": "inline",
}
return FileResponse(path=str(target), media_type=media_type, headers=headers)
# ---------------------------------------------------------------------------
# Admin-only endpoints
# ---------------------------------------------------------------------------
@router.get("/users", response_model=list[UserInfo])
async def get_users(_: TokenPayload = Depends(require_admin)) -> list[UserInfo]:
"""List all registered users. Requires admin role."""
return [UserInfo(**u) for u in list_users()]
@router.post("/users", status_code=status.HTTP_201_CREATED)
async def admin_create_user(
body: RegisterRequest,
current: TokenPayload = Depends(require_admin),
) -> dict:
"""Admin-only: create a new user account.
Replaces the public ``/register`` flow once the first admin exists. The
new account is always created with role=``user``; admins can promote
later via ``PUT /users/{username}/role``.
"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Auth is disabled — user creation is not available.",
)
if POCKETBASE_ENABLED:
result = register_pb(username=body.username, email=body.username, password=body.password)
if not result:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Failed to create user — username may already be taken.",
)
logger.info(
f"Admin '{current.username if current else 'local'}' created PocketBase user "
f"'{body.username}'"
)
return {
"ok": True,
"user_id": result.get("id", ""),
"username": body.username,
"role": "user",
"is_admin": False,
}
existing = {u["username"] for u in list_users()}
if body.username in existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already taken",
)
add_user(body.username, body.password)
user_id = ""
role = "user"
for item in list_users():
if item.get("username") == body.username:
user_id = str(item.get("id") or "")
role = str(item.get("role") or "user")
break
logger.info(
f"Admin '{current.username if current else 'local'}' created user '{body.username}' "
f"(role={role!r})"
)
return {
"ok": True,
"user_id": user_id,
"username": body.username,
"role": role,
"is_admin": role == "admin",
}
@router.delete("/users/{username}", status_code=status.HTTP_200_OK)
async def remove_user(
username: str,
current: TokenPayload = Depends(require_admin),
) -> dict:
"""Delete a user. Admins cannot delete their own account."""
if current and username == current.username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot delete your own account",
)
# Capture the id before the record disappears so the avatar file can go too.
info = get_user_info(username)
removed = delete_user(username)
if not removed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user_id = str(info.get("id") or "") if info else ""
if user_id and _USER_ID_RE.match(user_id):
from deeptutor.multi_user.identity import delete_avatar_file
delete_avatar_file(user_id)
logger.info(f"Admin '{current.username if current else 'local'}' deleted user '{username}'")
return {"ok": True}
@router.put("/users/{username}/role", status_code=status.HTTP_200_OK)
async def update_user_role(
username: str,
body: SetRoleRequest,
current: TokenPayload = Depends(require_admin),
) -> dict:
"""Change a user's role. Admins cannot change their own role."""
if current and username == current.username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot change your own role",
)
updated = set_role(username, body.role)
if not updated:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
logger.info(
f"Admin '{current.username if current else 'local'}' set '{username}' role to {body.role!r}"
)
return {"ok": True, "username": username, "role": body.role}
+669
View File
@@ -0,0 +1,669 @@
"""
Book Engine API Router
======================
REST + WebSocket endpoints for the ``BookEngine``. Phase 1 surface:
create / confirm / compile / read / delete + a per-book event stream.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, Field
from deeptutor.book import (
BlockType,
BookProposal,
Spine,
get_book_engine,
)
from deeptutor.book.models import ContentType
from deeptutor.book.streaming import SOURCE as BOOK_SOURCE
from deeptutor.core.stream import StreamEventType
from deeptutor.core.stream_bus import StreamBus
router = APIRouter()
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# Request / response models
# ─────────────────────────────────────────────────────────────────────────────
class CreateBookRequest(BaseModel):
user_intent: str = Field(default="")
chat_session_id: str = Field(default="")
chat_selections: list[dict[str, Any]] = Field(default_factory=list)
notebook_refs: list[dict[str, Any]] = Field(default_factory=list)
knowledge_bases: list[str] = Field(default_factory=list)
question_categories: list[int] = Field(default_factory=list)
question_entries: list[int] = Field(default_factory=list)
language: str = Field(default="en")
class ConfirmProposalRequest(BaseModel):
book_id: str
proposal: dict[str, Any] | None = None # full edited BookProposal payload
class ConfirmSpineRequest(BaseModel):
book_id: str
spine: dict[str, Any] | None = None
auto_compile: bool = True
class CompilePageRequest(BaseModel):
book_id: str
page_id: str
force: bool = False
class RegenerateBlockRequest(BaseModel):
book_id: str
page_id: str
block_id: str
params_override: dict[str, Any] | None = None
class InsertBlockRequest(BaseModel):
book_id: str
page_id: str
block_type: str
params: dict[str, Any] | None = None
position: int | None = None
compile_now: bool = True
class DeleteBlockRequest(BaseModel):
book_id: str
page_id: str
block_id: str
class MoveBlockRequest(BaseModel):
book_id: str
page_id: str
block_id: str
new_position: int
class ChangeBlockTypeRequest(BaseModel):
book_id: str
page_id: str
block_id: str
new_type: str
params_override: dict[str, Any] | None = None
class DeepDiveRequest(BaseModel):
book_id: str
parent_page_id: str
topic: str
block_id: str | None = None
content_type: str = "concept"
class QuizAttemptRequest(BaseModel):
book_id: str
page_id: str
block_id: str
question_id: str = ""
user_answer: str = ""
is_correct: bool = False
request_remediation: bool = False
class SupplementRequest(BaseModel):
book_id: str
page_id: str
topic: str
class PageChatSessionRequest(BaseModel):
book_id: str
page_id: str
session_id: str
class RebuildBookRequest(BaseModel):
book_id: str
auto_compile: bool = True
# ─────────────────────────────────────────────────────────────────────────────
# REST endpoints
# ─────────────────────────────────────────────────────────────────────────────
@router.get("/health")
async def health_check() -> dict[str, str]:
return {"status": "healthy", "service": "book"}
@router.get("/books")
async def list_books() -> dict[str, Any]:
engine = get_book_engine()
return {"books": [b.model_dump(mode="json") for b in engine.list_books()]}
@router.get("/books/{book_id}")
async def get_book(book_id: str) -> dict[str, Any]:
engine = get_book_engine()
book = engine.load_book(book_id)
if book is None:
raise HTTPException(status_code=404, detail="Book not found")
spine = engine.load_spine(book_id)
pages = engine.list_pages(book_id)
progress = engine.load_progress(book_id)
return {
"book": book.model_dump(mode="json"),
"spine": spine.model_dump(mode="json") if spine else None,
"pages": [p.model_dump(mode="json") for p in pages],
"progress": progress.model_dump(mode="json"),
}
@router.get("/books/{book_id}/spine")
async def get_spine(book_id: str) -> dict[str, Any]:
engine = get_book_engine()
spine = engine.load_spine(book_id)
if spine is None:
raise HTTPException(status_code=404, detail="Spine not found")
return {"spine": spine.model_dump(mode="json")}
@router.get("/books/{book_id}/pages/{page_id}")
async def get_page(book_id: str, page_id: str) -> dict[str, Any]:
engine = get_book_engine()
page = engine.load_page(book_id, page_id)
if page is None:
raise HTTPException(status_code=404, detail="Page not found")
return {"page": page.model_dump(mode="json")}
@router.delete("/books/{book_id}")
async def delete_book(book_id: str) -> dict[str, Any]:
engine = get_book_engine()
ok = engine.delete_book(book_id)
if not ok:
raise HTTPException(status_code=404, detail="Book not found")
return {"deleted": True, "book_id": book_id}
@router.post("/books")
async def create_book(req: CreateBookRequest) -> dict[str, Any]:
"""Stage 1: capture inputs + run IdeationAgent."""
if not req.user_intent.strip():
raise HTTPException(status_code=400, detail="user_intent is required")
engine = get_book_engine()
try:
book, proposal = await engine.create_book(
user_intent=req.user_intent,
chat_session_id=req.chat_session_id,
chat_selections=req.chat_selections,
notebook_refs=req.notebook_refs,
knowledge_bases=req.knowledge_bases,
question_categories=req.question_categories,
question_entries=req.question_entries,
language=req.language,
)
except Exception as exc: # noqa: BLE001
logger.error(f"create_book failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
return {
"book": book.model_dump(mode="json"),
"proposal": proposal.model_dump(mode="json"),
}
@router.post("/books/confirm-proposal")
async def confirm_proposal(req: ConfirmProposalRequest) -> dict[str, Any]:
"""Stage 2: user confirms (and possibly edits) the proposal → SpineAgent."""
engine = get_book_engine()
edited: BookProposal | None = None
if req.proposal:
try:
edited = BookProposal.model_validate(req.proposal)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Invalid proposal: {exc}")
try:
book, spine = await engine.confirm_proposal(book_id=req.book_id, edited_proposal=edited)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc: # noqa: BLE001
logger.error(f"confirm_proposal failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
return {
"book": book.model_dump(mode="json"),
"spine": spine.model_dump(mode="json"),
}
@router.post("/books/confirm-spine")
async def confirm_spine(req: ConfirmSpineRequest) -> dict[str, Any]:
"""Stage 3: user confirms the spine → create pending page shells."""
engine = get_book_engine()
edited: Spine | None = None
if req.spine:
try:
edited = Spine.model_validate(req.spine)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Invalid spine: {exc}")
try:
pages = await engine.confirm_spine(
book_id=req.book_id,
edited_spine=edited,
auto_compile=req.auto_compile,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc: # noqa: BLE001
logger.error(f"confirm_spine failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
return {"pages": [p.model_dump(mode="json") for p in pages]}
@router.post("/books/compile-page")
async def compile_page(req: CompilePageRequest) -> dict[str, Any]:
"""Drive the compiler for the page the user just opened (current-page priority)."""
engine = get_book_engine()
try:
page = await engine.compile_page(book_id=req.book_id, page_id=req.page_id, force=req.force)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc: # noqa: BLE001
logger.error(f"compile_page failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
return {"page": page.model_dump(mode="json")}
@router.post("/books/regenerate-block")
async def regenerate_block(req: RegenerateBlockRequest) -> dict[str, Any]:
engine = get_book_engine()
try:
block = await engine.regenerate_block(
book_id=req.book_id,
page_id=req.page_id,
block_id=req.block_id,
params_override=req.params_override,
)
except Exception as exc: # noqa: BLE001
logger.error(f"regenerate_block failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
if block is None:
raise HTTPException(status_code=404, detail="Block not found")
return {"block": block.model_dump(mode="json")}
def _coerce_block_type(name: str) -> BlockType:
try:
return BlockType(name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Unknown block type: {name}") from exc
def _coerce_content_type(name: str) -> ContentType:
try:
return ContentType(name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Unknown content type: {name}") from exc
@router.post("/books/insert-block")
async def insert_block(req: InsertBlockRequest) -> dict[str, Any]:
engine = get_book_engine()
block_type = _coerce_block_type(req.block_type)
try:
block = await engine.insert_block(
book_id=req.book_id,
page_id=req.page_id,
block_type=block_type,
params=req.params,
position=req.position,
compile_now=req.compile_now,
)
except Exception as exc: # noqa: BLE001
logger.error(f"insert_block failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
if block is None:
raise HTTPException(status_code=404, detail="Page or chapter not found")
return {"block": block.model_dump(mode="json")}
@router.post("/books/delete-block")
async def delete_block(req: DeleteBlockRequest) -> dict[str, Any]:
engine = get_book_engine()
ok = await engine.delete_block(book_id=req.book_id, page_id=req.page_id, block_id=req.block_id)
if not ok:
raise HTTPException(status_code=404, detail="Block not found")
return {"ok": True}
@router.post("/books/move-block")
async def move_block(req: MoveBlockRequest) -> dict[str, Any]:
engine = get_book_engine()
ok = await engine.move_block(
book_id=req.book_id,
page_id=req.page_id,
block_id=req.block_id,
new_position=req.new_position,
)
if not ok:
raise HTTPException(status_code=404, detail="Block not found")
return {"ok": True}
@router.post("/books/change-block-type")
async def change_block_type(req: ChangeBlockTypeRequest) -> dict[str, Any]:
engine = get_book_engine()
new_type = _coerce_block_type(req.new_type)
try:
block = await engine.change_block_type(
book_id=req.book_id,
page_id=req.page_id,
block_id=req.block_id,
new_type=new_type,
params_override=req.params_override,
)
except Exception as exc: # noqa: BLE001
logger.error(f"change_block_type failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
if block is None:
raise HTTPException(status_code=404, detail="Block not found")
return {"block": block.model_dump(mode="json")}
@router.post("/books/deep-dive")
async def deep_dive(req: DeepDiveRequest) -> dict[str, Any]:
engine = get_book_engine()
content_type = _coerce_content_type(req.content_type)
try:
page = await engine.create_deep_dive_subpage(
book_id=req.book_id,
parent_page_id=req.parent_page_id,
topic=req.topic,
block_id=req.block_id,
content_type=content_type,
)
except Exception as exc: # noqa: BLE001
logger.error(f"deep_dive failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
if page is None:
raise HTTPException(status_code=404, detail="Parent page not found")
return {"page": page.model_dump(mode="json")}
@router.post("/books/quiz-attempt")
async def quiz_attempt(req: QuizAttemptRequest) -> dict[str, Any]:
engine = get_book_engine()
progress = await engine.record_quiz_attempt(
book_id=req.book_id,
page_id=req.page_id,
block_id=req.block_id,
question_id=req.question_id,
user_answer=req.user_answer,
is_correct=req.is_correct,
)
return {"progress": progress.model_dump(mode="json")}
@router.get("/books/{book_id}/health")
async def book_health(book_id: str) -> dict[str, Any]:
engine = get_book_engine()
drift = engine.kb_drift_report(book_id)
log = engine.log_health(book_id)
return {"kb_drift": drift, "log_health": log}
@router.post("/books/{book_id}/refresh-fingerprints")
async def refresh_fingerprints(book_id: str) -> dict[str, Any]:
engine = get_book_engine()
result = engine.refresh_kb_fingerprints(book_id)
if result is None:
raise HTTPException(status_code=404, detail="Book not found")
return result
@router.post("/books/supplement")
async def supplement(req: SupplementRequest) -> dict[str, Any]:
engine = get_book_engine()
try:
block = await engine.supplement_for_weakness(
book_id=req.book_id,
page_id=req.page_id,
topic=req.topic,
)
except Exception as exc: # noqa: BLE001
logger.error(f"supplement failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
if block is None:
raise HTTPException(status_code=404, detail="Page not found")
return {"block": block.model_dump(mode="json")}
@router.post("/books/page-chat-session")
async def set_page_chat_session(req: PageChatSessionRequest) -> dict[str, Any]:
engine = get_book_engine()
book = engine.set_page_chat_session(
book_id=req.book_id,
page_id=req.page_id,
session_id=req.session_id,
)
if book is None:
raise HTTPException(status_code=404, detail="Book or page not found")
return {"book": book.model_dump(mode="json")}
@router.post("/books/rebuild")
async def rebuild_book(req: RebuildBookRequest) -> dict[str, Any]:
engine = get_book_engine()
try:
pages = await engine.rebuild_book(book_id=req.book_id, auto_compile=req.auto_compile)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc: # noqa: BLE001
logger.error(f"rebuild_book failed: {exc}", exc_info=True)
raise HTTPException(status_code=500, detail=str(exc))
return {"pages": [p.model_dump(mode="json") for p in pages]}
# ─────────────────────────────────────────────────────────────────────────────
# WebSocket streamed Book events
# ─────────────────────────────────────────────────────────────────────────────
def _serialize_event(event) -> dict[str, Any]:
return {
"type": event.type.value if hasattr(event.type, "value") else str(event.type),
"source": event.source,
"stage": event.stage,
"content": event.content,
"metadata": event.metadata or {},
}
@router.websocket("/ws")
async def book_websocket(ws: WebSocket) -> None:
"""Streaming endpoint.
Client message protocol::
{"type": "create", ...CreateBookRequest fields}
{"type": "confirm_proposal", "book_id": "...", "proposal": {...}}
{"type": "confirm_spine", "book_id": "...", "spine": {...}, "auto_compile": true}
{"type": "compile_page", "book_id": "...", "page_id": "..."}
{"type": "regenerate_block", "book_id": "...", "page_id": "...", "block_id": "...", "params_override": {}}
"""
from deeptutor.api.routers.auth import ws_auth_failed, ws_require_auth
from deeptutor.multi_user.context import reset_current_user
user_token = await ws_require_auth(ws)
if user_token is ws_auth_failed:
return
await ws.accept()
closed = False
async def send(data: dict[str, Any]) -> None:
nonlocal closed
if closed:
return
try:
await ws.send_json(data)
except Exception:
closed = True
async def stream_into_socket(bus: StreamBus) -> asyncio.Task:
async def _forward() -> None:
async for event in bus.subscribe():
if event.source != BOOK_SOURCE:
continue
await send(_serialize_event(event))
if event.type == StreamEventType.STAGE_END and event.stage in {
"ideation",
"spine",
"compilation",
}:
pass # keep streaming multiple stages per task
return asyncio.create_task(_forward())
try:
engine = get_book_engine()
while not closed:
try:
data = await ws.receive_json()
except WebSocketDisconnect:
break
except Exception as exc:
await send({"type": "error", "content": f"Bad message: {exc}"})
continue
msg_type = str(data.get("type") or "").strip()
if not msg_type:
await send({"type": "error", "content": "Missing 'type' field"})
continue
bus = StreamBus()
forward_task = await stream_into_socket(bus)
try:
if msg_type == "create":
book, proposal = await engine.create_book(
user_intent=str(data.get("user_intent") or ""),
chat_session_id=str(data.get("chat_session_id") or ""),
chat_selections=data.get("chat_selections") or [],
notebook_refs=data.get("notebook_refs") or [],
knowledge_bases=data.get("knowledge_bases") or [],
question_categories=[
int(c) for c in (data.get("question_categories") or [])
],
question_entries=[int(e) for e in (data.get("question_entries") or [])],
language=str(data.get("language") or "en"),
stream=bus,
)
await send(
{
"type": "create_result",
"book": book.model_dump(mode="json"),
"proposal": proposal.model_dump(mode="json"),
}
)
elif msg_type == "confirm_proposal":
edited: BookProposal | None = None
if data.get("proposal"):
edited = BookProposal.model_validate(data["proposal"])
book, spine = await engine.confirm_proposal(
book_id=str(data.get("book_id") or ""),
edited_proposal=edited,
stream=bus,
)
await send(
{
"type": "confirm_proposal_result",
"book": book.model_dump(mode="json"),
"spine": spine.model_dump(mode="json"),
}
)
elif msg_type == "confirm_spine":
edited_spine: Spine | None = None
if data.get("spine"):
edited_spine = Spine.model_validate(data["spine"])
pages = await engine.confirm_spine(
book_id=str(data.get("book_id") or ""),
edited_spine=edited_spine,
auto_compile=bool(data.get("auto_compile", True)),
stream=bus,
)
await send(
{
"type": "confirm_spine_result",
"pages": [p.model_dump(mode="json") for p in pages],
}
)
elif msg_type == "compile_page":
page = await engine.compile_page(
book_id=str(data.get("book_id") or ""),
page_id=str(data.get("page_id") or ""),
stream=bus,
force=bool(data.get("force", False)),
)
await send(
{
"type": "compile_page_result",
"page": page.model_dump(mode="json"),
}
)
elif msg_type == "regenerate_block":
block = await engine.regenerate_block(
book_id=str(data.get("book_id") or ""),
page_id=str(data.get("page_id") or ""),
block_id=str(data.get("block_id") or ""),
params_override=data.get("params_override"),
stream=bus,
)
await send(
{
"type": "regenerate_block_result",
"block": block.model_dump(mode="json") if block else None,
}
)
else:
await send({"type": "error", "content": f"Unknown message type: {msg_type}"})
except Exception as exc:
logger.error(f"book ws action {msg_type} failed: {exc}", exc_info=True)
await send({"type": "error", "content": str(exc)})
finally:
await bus.close()
forward_task.cancel()
try:
await forward_task
except (asyncio.CancelledError, Exception):
pass
except WebSocketDisconnect:
pass
except Exception as exc:
logger.error(f"Book WS connection error: {exc}", exc_info=True)
finally:
closed = True
try:
await ws.close()
except Exception:
pass
if user_token is not None:
try:
reset_current_user(user_token)
except Exception:
pass
@@ -0,0 +1,38 @@
"""Capabilities settings endpoint.
Surfaces the per-capability tunables (temperature, max_tokens, stage
budgets, iteration limits) currently scattered across
``data/user/settings/agents.yaml`` and ``data/user/settings/main.yaml``.
Mirrors the pattern used by ``/api/v1/memory/settings``:
* ``GET /settings`` → returns the full schema with defaults merged in.
* ``PUT /settings`` → merges payload back into both YAML files and
returns the new state.
Validation lives in
:mod:`deeptutor.services.config.capabilities_settings` so the API stays
a thin transport layer.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter
router = APIRouter()
@router.get("/settings")
async def get_capabilities_settings_endpoint() -> dict[str, Any]:
from deeptutor.services.config.capabilities_settings import capabilities_settings_dict
return capabilities_settings_dict()
@router.put("/settings")
async def put_capabilities_settings(payload: dict[str, Any]) -> dict[str, Any]:
from deeptutor.services.config.capabilities_settings import save_capabilities_settings
return save_capabilities_settings(payload)
+249
View File
@@ -0,0 +1,249 @@
"""
Chat API Router
================
WebSocket endpoint for lightweight chat with session management.
REST endpoints for session operations.
"""
import logging
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from deeptutor.agents.chat import ChatAgent, SessionManager
from deeptutor.services.config import PROJECT_ROOT, load_config_with_main
from deeptutor.services.llm.config import get_llm_config
from deeptutor.services.settings.interface_settings import get_ui_language
config = load_config_with_main("main.yaml", PROJECT_ROOT)
log_dir = config.get("paths", {}).get("user_log_dir") or config.get("logging", {}).get("log_dir")
logger = logging.getLogger(__name__)
router = APIRouter()
def _get_session_manager() -> SessionManager:
return SessionManager()
# =============================================================================
# REST Endpoints for Session Management
# =============================================================================
@router.get("/chat/sessions")
async def list_sessions(limit: int = 20):
return _get_session_manager().list_sessions(limit=limit, include_messages=False)
@router.get("/chat/sessions/{session_id}")
async def get_session(session_id: str):
session = _get_session_manager().get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return session
@router.delete("/chat/sessions/{session_id}")
async def delete_session(session_id: str):
if _get_session_manager().delete_session(session_id):
return {"status": "deleted", "session_id": session_id}
raise HTTPException(status_code=404, detail="Session not found")
# =============================================================================
# WebSocket Endpoint for Chat
# =============================================================================
@router.websocket("/chat")
async def websocket_chat(websocket: WebSocket):
from deeptutor.api.routers.auth import ws_auth_failed, ws_require_auth
from deeptutor.multi_user.context import reset_current_user
user_token = await ws_require_auth(websocket)
if user_token is ws_auth_failed:
return
await websocket.accept()
try:
while True:
data = await websocket.receive_json()
requested_language = str(data.get("language") or "").lower().strip()
language = (
"zh"
if requested_language.startswith("zh")
else "en"
if requested_language.startswith("en")
else get_ui_language(default=config.get("system", {}).get("language", "en"))
)
message = data.get("message", "").strip()
session_id = data.get("session_id")
explicit_history = data.get("history")
kb_name = data.get("kb_name", "")
enable_rag = data.get("enable_rag", False)
enable_web_search = data.get("enable_web_search", False)
if not message:
await websocket.send_json({"type": "error", "message": "Message is required"})
continue
logger.info(
f"Chat request: session={session_id}, "
f"message={message[:50]}..., rag={enable_rag}, web={enable_web_search}"
)
try:
sm = _get_session_manager()
if session_id:
session = sm.get_session(session_id)
if not session:
session = sm.create_session(
title=message[:50] + ("..." if len(message) > 50 else ""),
settings={
"kb_name": kb_name,
"enable_rag": enable_rag,
"enable_web_search": enable_web_search,
},
)
session_id = session["session_id"]
else:
session = sm.create_session(
title=message[:50] + ("..." if len(message) > 50 else ""),
settings={
"kb_name": kb_name,
"enable_rag": enable_rag,
"enable_web_search": enable_web_search,
},
)
session_id = session["session_id"]
await websocket.send_json(
{
"type": "session",
"session_id": session_id,
}
)
if explicit_history is not None:
history = explicit_history
else:
history = [
{"role": msg["role"], "content": msg["content"]}
for msg in session.get("messages", [])
]
sm.add_message(
session_id=session_id,
role="user",
content=message,
)
try:
llm_config = get_llm_config()
api_key = llm_config.api_key
base_url = llm_config.base_url
api_version = getattr(llm_config, "api_version", None)
except Exception:
api_key = None
base_url = None
api_version = None
agent = ChatAgent(
language=language,
config=config,
api_key=api_key,
base_url=base_url,
api_version=api_version,
)
if enable_rag and kb_name:
await websocket.send_json(
{
"type": "status",
"stage": "rag",
"message": f"Searching knowledge base: {kb_name}...",
}
)
if enable_web_search:
await websocket.send_json(
{
"type": "status",
"stage": "web",
"message": "Searching the web...",
}
)
await websocket.send_json(
{
"type": "status",
"stage": "generating",
"message": "Generating response...",
}
)
full_response = ""
sources = {"rag": [], "web": []}
stream_generator = await agent.process(
message=message,
history=history,
kb_name=kb_name,
enable_rag=enable_rag,
enable_web_search=enable_web_search,
stream=True,
)
async for chunk_data in stream_generator:
if chunk_data["type"] == "chunk":
await websocket.send_json(
{
"type": "stream",
"content": chunk_data["content"],
}
)
full_response += chunk_data["content"]
elif chunk_data["type"] == "complete":
full_response = chunk_data["response"]
sources = chunk_data.get("sources", {"rag": [], "web": []})
if sources.get("rag") or sources.get("web"):
await websocket.send_json({"type": "sources", **sources})
await websocket.send_json(
{
"type": "result",
"content": full_response,
}
)
sm.add_message(
session_id=session_id,
role="assistant",
content=full_response,
sources=sources if (sources.get("rag") or sources.get("web")) else None,
)
logger.info(f"Chat completed: session={session_id}, {len(full_response)} chars")
except Exception as e:
logger.error(f"Chat processing error: {e}")
await websocket.send_json({"type": "error", "message": str(e)})
except WebSocketDisconnect:
logger.debug("Client disconnected from chat")
except Exception as e:
logger.error(f"WebSocket error: {e}")
try:
await websocket.send_json({"type": "error", "message": str(e)})
except Exception:
pass
finally:
if user_token is not None:
try:
reset_current_user(user_token)
except Exception:
pass
+618
View File
@@ -0,0 +1,618 @@
import asyncio
from datetime import datetime
import json
import logging
import re
import traceback
from typing import AsyncGenerator, Literal
import uuid
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from deeptutor.co_writer.edit_agent import (
EditAgent,
append_history,
load_history,
print_stats,
tool_calls_dir,
)
from deeptutor.co_writer.storage import (
CoWriterDocument,
CoWriterDocumentSummary,
get_co_writer_storage,
)
from deeptutor.core.stream_bus import StreamBus
from deeptutor.services.config import PROJECT_ROOT, load_config_with_main
from deeptutor.services.llm import clean_thinking_tags
from deeptutor.services.settings.interface_settings import get_ui_language
router = APIRouter()
# Initialize logger with config
config = load_config_with_main("main.yaml", PROJECT_ROOT)
log_dir = config.get("paths", {}).get("user_log_dir") or config.get("logging", {}).get("log_dir")
logger = logging.getLogger(__name__)
_edit_agent: EditAgent | None = None
def _current_language() -> str:
# Prefer UI settings, fall back to main.yaml system.language
return get_ui_language(default=config.get("system", {}).get("language", "en"))
def get_edit_agent() -> EditAgent:
"""
Get the singleton EditAgent instance with refreshed configuration.
Uses a singleton pattern with refresh_config() to ensure:
1. Efficient reuse of the agent instance
2. Latest LLM configuration from Settings is always used
"""
global _edit_agent
lang = _current_language()
if _edit_agent is None or getattr(_edit_agent, "language", None) != lang:
_edit_agent = EditAgent(language=lang)
# Refresh config to pick up any changes from Settings
_edit_agent.refresh_config()
return _edit_agent
# Generous ceilings — they exist to stop runaway payloads (OOM / surprise
# LLM bills), not to constrain normal documents.
_MAX_DOC_CHARS = 600_000
_MAX_SELECTION_CHARS = 120_000
_MAX_INSTRUCTION_CHARS = 10_000
class EditRequest(BaseModel):
text: str = Field(max_length=_MAX_DOC_CHARS)
instruction: str = Field(max_length=_MAX_INSTRUCTION_CHARS)
action: Literal["rewrite", "shorten", "expand"] = "rewrite"
source: Literal["rag", "web"] | None = None
kb_name: str | None = None
class EditResponse(BaseModel):
edited_text: str
operation_id: str
class ReactEditRequest(BaseModel):
selected_text: str = Field(max_length=_MAX_SELECTION_CHARS)
instruction: str = Field(default="", max_length=_MAX_INSTRUCTION_CHARS)
mode: Literal["rewrite", "shorten", "expand", "none"] = "rewrite"
tools: list[Literal["rag", "web"]] = []
kb_name: str | None = None
class ReactEditResponse(BaseModel):
edited_text: str
operation_id: str
tools_used: list[str] = []
class AutoMarkRequest(BaseModel):
text: str = Field(max_length=_MAX_DOC_CHARS)
class AutoMarkResponse(BaseModel):
marked_text: str
operation_id: str
def _default_mode_instruction(mode: str, language: str) -> str:
zh = language.startswith("zh")
defaults = {
"rewrite": "润色这段 markdown,保持原意、结构和语气自然。",
"shorten": "压缩这段 markdown,让表达更精炼,同时保留关键信息。",
"expand": "扩展这段 markdown,补充必要细节,同时保持原有风格。",
"none": "根据用户要求编辑这段 markdown。",
}
if zh:
return defaults.get(mode, defaults["none"])
defaults_en = {
"rewrite": "Rewrite this markdown snippet while preserving its meaning, structure, and tone.",
"shorten": "Shorten this markdown snippet while preserving the key information.",
"expand": "Expand this markdown snippet with helpful detail while keeping the original style.",
"none": "Edit this markdown snippet according to the user's request.",
}
return defaults_en.get(mode, defaults_en["none"])
def _build_react_edit_prompt(
*,
selected_text: str,
instruction: str,
mode: str,
language: str,
context: str = "",
) -> str:
user_instruction = instruction.strip() or _default_mode_instruction(mode, language)
if language.startswith("zh"):
context_block = f"参考资料(按需取用,不必全部使用):\n{context}\n\n" if context else ""
return (
"你正在编辑一段从 Markdown 编辑器里选中的文本。\n\n"
f"编辑模式: {mode}\n"
f"用户要求: {user_instruction}\n\n"
f"{context_block}"
"待编辑的选中文本:\n"
"```markdown\n"
f"{selected_text}\n"
"```\n\n"
"要求:\n"
"1. 只输出编辑后的那段 Markdown 文本,供编辑器直接替换。\n"
"2. 不要输出解释、标题、前后缀、代码围栏。\n"
"3. 保持 Markdown 语法合法。\n"
"4. 如果给了参考资料,把相关事实自然融入结果,不要提工具或资料来源。\n"
)
context_block = (
f"Reference material (use what is relevant, ignore the rest):\n{context}\n\n"
if context
else ""
)
return (
"You are editing a text selection from a Markdown editor.\n\n"
f"Edit mode: {mode}\n"
f"User request: {user_instruction}\n\n"
f"{context_block}"
"Selected text to edit:\n"
"```markdown\n"
f"{selected_text}\n"
"```\n\n"
"Requirements:\n"
"1. Output only the edited Markdown snippet for direct replacement.\n"
"2. Do not include explanations, headings, prefixes, suffixes, or code fences.\n"
"3. Keep the Markdown valid.\n"
"4. If reference material is given, weave the relevant facts in naturally "
"without mentioning tools or sources.\n"
)
def _strip_markdown_fence(text: str) -> str:
cleaned = text.strip()
if cleaned.startswith("```") and cleaned.endswith("```"):
lines = cleaned.splitlines()
if len(lines) >= 3:
return "\n".join(lines[1:-1]).strip()
return cleaned
def _clean_react_edit_output(text: str, *, binding: str | None, model: str | None) -> str:
return _strip_markdown_fence(clean_thinking_tags(text, binding, model))
def _normalize_react_edit_tools(tools: list[str] | None) -> list[str]:
allowed = {"rag", "web"}
result: list[str] = []
for tool in tools or []:
name = str(tool or "").strip()
if name in allowed and name not in result:
result.append(name)
return result
def _prepare_react_edit_request(
request: ReactEditRequest, language: str
) -> tuple[str, str, list[str]]:
tools = _normalize_react_edit_tools(request.tools)
instruction = request.instruction.strip()
if request.mode == "none" and not instruction:
detail = (
"请输入编辑要求,或选择 shorten / expand / rewrite 模式。"
if language.startswith("zh")
else "Provide an edit instruction, or choose shorten / expand / rewrite mode."
)
raise HTTPException(status_code=400, detail=detail)
selected_text = request.selected_text.strip("\n")
if not selected_text.strip():
detail = (
"请先选中一段文本。"
if language.startswith("zh")
else "Please select a text passage first."
)
raise HTTPException(status_code=400, detail=detail)
return selected_text, instruction, tools
_TRACE_PREVIEW_CHARS = 1200
def _trace_preview(text: str) -> str:
cleaned = text.strip()
if len(cleaned) <= _TRACE_PREVIEW_CHARS:
return cleaned
return cleaned[:_TRACE_PREVIEW_CHARS].rstrip() + ""
async def _run_react_edit(
request: ReactEditRequest,
*,
language: str,
stream: StreamBus | None = None,
) -> dict[str, object]:
selected_text, instruction, tools = _prepare_react_edit_request(request, language)
operation_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
agent = get_edit_agent()
# Optional reference retrieval before the edit. Each tool degrades to a
# plain edit on failure — retrieval must never block the user's edit.
query = instruction or selected_text[:400]
context_blocks: list[str] = []
tools_used: list[str] = []
for tool in tools:
kb_name = request.kb_name if tool == "rag" else None
if tool == "rag" and not kb_name:
continue
if stream is not None:
await stream.tool_call(
tool,
{"query": query, **({"kb_name": kb_name} if kb_name else {})},
source="co_writer_react_edit",
stage="exploring",
)
context, _file = await agent.gather_context(
source=tool,
query=query,
kb_name=kb_name,
operation_id=operation_id,
)
if stream is not None:
await stream.tool_result(
tool,
_trace_preview(context) if context else "(no result)",
source="co_writer_react_edit",
stage="exploring",
)
if context:
context_blocks.append(context)
tools_used.append(tool)
system_prompt = (
"You are an expert markdown editor."
if not language.startswith("zh")
else "你是一个严格的 Markdown 编辑助手。"
)
prompt = _build_react_edit_prompt(
selected_text=selected_text,
instruction=instruction,
mode=request.mode,
language=language,
context="\n\n".join(context_blocks),
)
response_chunks: list[str] = []
async def _consume() -> None:
async for chunk in agent.stream_llm(
user_prompt=prompt,
system_prompt=system_prompt,
stage=f"react_edit_{request.mode}",
):
if not chunk:
continue
response_chunks.append(chunk)
if stream is not None:
await stream.content(
chunk,
source="co_writer_react_edit",
stage="responding",
)
if stream is not None:
async with stream.stage("responding", source="co_writer_react_edit"):
await _consume()
else:
await _consume()
edited_text = _clean_react_edit_output(
"".join(response_chunks),
binding=agent.binding,
model=agent.get_model(),
)
append_history(
{
"id": operation_id,
"timestamp": datetime.now().isoformat(),
"action": "react_edit",
"mode": request.mode,
"tools": tools_used,
"kb_name": request.kb_name,
"input": {
"selected_text": request.selected_text,
"instruction": instruction,
},
"output": {"edited_text": edited_text},
"model": agent.get_model(),
}
)
print_stats()
result = {
"edited_text": edited_text,
"operation_id": operation_id,
"tools_used": tools_used,
}
if stream is not None:
await stream.result(result, source="co_writer_react_edit")
return result
async def _stream_react_edit(request: ReactEditRequest) -> AsyncGenerator[str, None]:
language = _current_language()
bus = StreamBus()
error_holder: dict[str, str] = {}
result_holder: dict[str, object] | None = None
async def _run() -> None:
nonlocal result_holder
try:
result_holder = await _run_react_edit(request, language=language, stream=bus)
except HTTPException as exc:
error_holder["detail"] = str(exc.detail)
except Exception as exc:
error_holder["detail"] = str(exc)
finally:
await bus.close()
task = asyncio.create_task(_run())
try:
async for event in bus.subscribe():
yield f"event: stream\ndata: {json.dumps(event.to_dict(), default=str)}\n\n"
await task
if error_holder:
yield f"event: error\ndata: {json.dumps(error_holder, default=str)}\n\n"
else:
yield f"event: result\ndata: {json.dumps(result_holder or {}, default=str)}\n\n"
finally:
if not task.done():
task.cancel()
@router.post("/edit", response_model=EditResponse)
async def edit_text(request: EditRequest):
try:
# Get agent with refreshed LLM configuration from Settings
agent = get_edit_agent()
result = await agent.process(
text=request.text,
instruction=request.instruction,
action=request.action,
source=request.source,
kb_name=request.kb_name,
)
# Print token stats
print_stats()
return result
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/edit_react", response_model=ReactEditResponse)
async def edit_text_react(request: ReactEditRequest):
try:
return await _run_react_edit(request, language=_current_language())
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/edit_react/stream")
async def edit_text_react_stream(request: ReactEditRequest):
try:
_prepare_react_edit_request(request, _current_language())
except HTTPException:
raise
return StreamingResponse(
_stream_react_edit(request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("/automark", response_model=AutoMarkResponse)
async def auto_mark_text(request: AutoMarkRequest):
"""AI auto-mark text"""
try:
# Get agent with refreshed LLM configuration from Settings
agent = get_edit_agent()
result = await agent.auto_mark(text=request.text)
# Print token stats
print_stats()
return result
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/history")
async def get_history():
"""Get all operation history"""
try:
history = load_history()
return {"history": history, "total": len(history)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/history/{operation_id}")
async def get_operation(operation_id: str):
"""Get single operation details"""
try:
history = load_history()
for op in history:
if op.get("id") == operation_id:
return op
raise HTTPException(status_code=404, detail="Operation not found")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/tool_calls/{operation_id}")
async def get_tool_call(operation_id: str):
"""Get tool call details"""
try:
# Find matching file
for filepath in tool_calls_dir().glob(f"{operation_id}_*.json"):
with open(filepath, encoding="utf-8") as f:
return json.load(f)
raise HTTPException(status_code=404, detail="Tool call not found")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ─────────────────────────────────────────────────────────────────────────────
# Document CRUD (multi-project Co-Writer)
# ─────────────────────────────────────────────────────────────────────────────
# Storage builds paths as `documents/doc_{doc_id}`; an unvalidated id like
# "a/../../x" would escape the documents root (and DELETE runs rmtree).
_DOC_ID_RE = re.compile(r"^[0-9a-f]{8,32}$")
def _validate_doc_id(doc_id: str) -> str:
if not _DOC_ID_RE.fullmatch(doc_id):
raise HTTPException(status_code=404, detail="Document not found")
return doc_id
class CreateDocumentRequest(BaseModel):
title: str | None = None
content: str = ""
class UpdateDocumentRequest(BaseModel):
title: str | None = None
content: str | None = None
class DocumentResponse(BaseModel):
id: str
title: str
content: str
created_at: float
updated_at: float
@classmethod
def from_model(cls, doc: CoWriterDocument) -> "DocumentResponse":
return cls(
id=doc.id,
title=doc.title,
content=doc.content,
created_at=doc.created_at,
updated_at=doc.updated_at,
)
class DocumentSummaryResponse(BaseModel):
id: str
title: str
created_at: float
updated_at: float
preview: str = ""
@classmethod
def from_summary(cls, summary: CoWriterDocumentSummary) -> "DocumentSummaryResponse":
return cls(
id=summary.id,
title=summary.title,
created_at=summary.created_at,
updated_at=summary.updated_at,
preview=summary.preview,
)
@router.get("/documents")
async def list_documents() -> dict[str, list[DocumentSummaryResponse]]:
"""List all Co-Writer documents (summary view, sorted by recency)."""
try:
storage = get_co_writer_storage()
summaries = storage.list_documents()
return {"documents": [DocumentSummaryResponse.from_summary(s) for s in summaries]}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/documents", response_model=DocumentResponse)
async def create_document(request: CreateDocumentRequest) -> DocumentResponse:
"""Create a new Co-Writer document."""
try:
storage = get_co_writer_storage()
document = storage.create_document(title=request.title, content=request.content)
return DocumentResponse.from_model(document)
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/documents/{doc_id}", response_model=DocumentResponse)
async def get_document(doc_id: str) -> DocumentResponse:
"""Get a single Co-Writer document by id."""
try:
storage = get_co_writer_storage()
document = storage.load_document(_validate_doc_id(doc_id))
if document is None:
raise HTTPException(status_code=404, detail="Document not found")
return DocumentResponse.from_model(document)
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.put("/documents/{doc_id}", response_model=DocumentResponse)
async def update_document(doc_id: str, request: UpdateDocumentRequest) -> DocumentResponse:
"""Update a Co-Writer document (title and/or content)."""
try:
storage = get_co_writer_storage()
document = storage.update_document(
_validate_doc_id(doc_id), title=request.title, content=request.content
)
if document is None:
raise HTTPException(status_code=404, detail="Document not found")
return DocumentResponse.from_model(document)
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/documents/{doc_id}")
async def delete_document(doc_id: str) -> dict[str, bool]:
"""Delete a Co-Writer document."""
try:
storage = get_co_writer_storage()
_validate_doc_id(doc_id)
if not storage.doc_exists(doc_id):
raise HTTPException(status_code=404, detail="Document not found")
success = storage.delete_document(doc_id)
return {"deleted": success}
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
+61
View File
@@ -0,0 +1,61 @@
"""Dashboard API backed by the unified SQLite session store."""
from typing import Any
from fastapi import APIRouter, HTTPException
from deeptutor.services.session import get_session_store
router = APIRouter()
@router.get("/recent")
async def get_recent_activities(limit: int = 50, type: str | None = None):
store = get_session_store()
sessions = await store.list_sessions(limit=limit, offset=0)
activities: list[dict[str, Any]] = []
for session in sessions:
capability = str(session.get("capability") or "chat")
activity_type = capability.replace("deep_", "")
if type is not None and activity_type != type:
continue
activities.append(
{
"id": session.get("session_id"),
"type": activity_type,
"capability": capability,
"title": session.get("title", "Untitled"),
"timestamp": session.get("updated_at", session.get("created_at", 0)),
"summary": (session.get("last_message") or "")[:160],
"session_ref": f"sessions/{session.get('session_id')}",
"message_count": session.get("message_count", 0),
"status": session.get("status", "idle"),
"active_turn_id": session.get("active_turn_id"),
}
)
return activities[:limit]
@router.get("/{entry_id}")
async def get_activity_entry(entry_id: str):
store = get_session_store()
session = await store.get_session_with_messages(entry_id)
if session is None:
raise HTTPException(status_code=404, detail="Entry not found")
capability = str(session.get("capability") or "chat")
return {
"id": session.get("session_id"),
"type": capability.replace("deep_", ""),
"capability": capability,
"title": session.get("title"),
"timestamp": session.get("updated_at", session.get("created_at")),
"content": {
"messages": session.get("messages", []),
"active_turns": session.get("active_turns", []),
"status": session.get("status", "idle"),
"summary": session.get("compressed_summary", ""),
},
}
+146
View File
@@ -0,0 +1,146 @@
"""
Import chat histories from external coding CLIs (Claude Code, Codex) into the
user's learning space as normal, re-openable sessions.
Reading the user's local ``~/.claude`` / ``~/.codex`` happens in the browser
(File System Access API) — those files live on the user's machine, not the
server. The browser normalizes each conversation to the small JSON shape below
and POSTs it here; this router only validates and persists. Imported sessions
share the session tables with native chats (so the chat loop can re-open and
continue them) but carry an ``imported_`` id prefix that keeps them in their
own Space category. Re-importing the same folder is idempotent (dedup by id).
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field, field_validator
from deeptutor.services.session import (
get_sqlite_session_store,
make_imported_session_id,
)
logger = logging.getLogger(__name__)
router = APIRouter()
# Browser adapters only emit these; reject anything else so a malformed payload
# can never seed an unsupported provider category.
_ALLOWED_SOURCES = {"claude_code", "codex"}
# Defensive ceilings — a single import request should never grow unbounded.
_MAX_SESSIONS_PER_REQUEST = 1000
_MAX_MESSAGES_PER_SESSION = 10000
class ImportedMessage(BaseModel):
role: str = Field(..., pattern="^(user|assistant)$")
content: str = ""
created_at: float | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ImportedSession(BaseModel):
external_id: str = Field(..., min_length=1, max_length=256)
title: str = ""
source_cwd: str = ""
created_at: float
updated_at: float
messages: list[ImportedMessage] = Field(default_factory=list)
class ChatHistoryImportRequest(BaseModel):
source: str = Field(..., min_length=1)
# All sessions in one request belong to one agent (a named, scoped slice of
# a source folder). Empty when the client hasn't adopted the agent model yet
# — the backend stays backwards-compatible by simply omitting attribution.
agent_id: str = Field(default="", max_length=256)
agent_name: str = Field(default="", max_length=256)
sessions: list[ImportedSession] = Field(default_factory=list)
@field_validator("source")
@classmethod
def _normalize_source(cls, value: str) -> str:
normalized = (value or "").strip().lower()
if normalized not in _ALLOWED_SOURCES:
raise ValueError(f"Unsupported import source: {value!r}")
return normalized
@router.post("/chat-history")
async def import_chat_history(payload: ChatHistoryImportRequest) -> dict[str, Any]:
if not payload.sessions:
raise HTTPException(status_code=400, detail="No sessions to import")
if len(payload.sessions) > _MAX_SESSIONS_PER_REQUEST:
raise HTTPException(
status_code=413,
detail=f"Too many sessions in one request (max {_MAX_SESSIONS_PER_REQUEST})",
)
store = get_sqlite_session_store()
imported = 0
skipped = 0
results: list[dict[str, Any]] = []
for incoming in payload.sessions:
# Drop content-less rows (e.g. tool-only turns the adapter could not
# reduce to text) so the transcript stays a clean human conversation.
messages = [m for m in incoming.messages if (m.content or "").strip()]
if not messages:
skipped += 1
results.append(
{"external_id": incoming.external_id, "imported": False, "reason": "empty"}
)
continue
if len(messages) > _MAX_MESSAGES_PER_SESSION:
messages = messages[:_MAX_MESSAGES_PER_SESSION]
session_id = make_imported_session_id(payload.source, incoming.external_id)
import_meta: dict[str, Any] = {
"source": payload.source,
"source_cwd": incoming.source_cwd,
"external_id": incoming.external_id,
}
if payload.agent_id:
import_meta["agent_id"] = payload.agent_id
if payload.agent_name:
import_meta["agent_name"] = payload.agent_name
preferences = {"import": import_meta}
try:
result = await store.import_session(
session_id,
incoming.title,
incoming.created_at,
incoming.updated_at,
preferences,
[m.model_dump() for m in messages],
)
except Exception:
logger.exception("failed to import session %s", incoming.external_id)
skipped += 1
results.append(
{"external_id": incoming.external_id, "imported": False, "reason": "error"}
)
continue
if result.get("imported"):
imported += 1
else:
skipped += 1
results.append({"external_id": incoming.external_id, **result})
return {"imported": imported, "skipped": skipped, "sessions": results}
@router.get("/chat-history")
async def list_imported_chat_history(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> dict[str, Any]:
store = get_sqlite_session_store()
sessions = await store.list_imported_sessions(limit=limit, offset=offset)
return {"sessions": sessions}
File diff suppressed because it is too large Load Diff
+308
View File
@@ -0,0 +1,308 @@
"""Guided Learning API Router."""
from __future__ import annotations
import html
import json
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from pydantic import ValidationError as PydanticValidationError
from deeptutor.learning import policy as learning_policy
from deeptutor.learning import prompts as learning_prompts
from deeptutor.learning.models import (
KnowledgePoint,
KnowledgeType,
LearningModule,
LearningStage,
)
from deeptutor.learning.service import LearningService
from deeptutor.learning.storage import LearningStore
from deeptutor.services.settings.interface_settings import get_ui_language
from deeptutor.utils.json_parser import parse_json_response
router = APIRouter()
def get_learning_service() -> LearningService:
# Create a fresh store + service per request to avoid object-level race conditions.
store = LearningStore()
return LearningService(store)
def _validate_book_id(book_id: str) -> None:
"""Reject empty or path-traversal-bearing book ids (shared by all endpoints)."""
if not book_id or ".." in book_id or "/" in book_id or "\\" in book_id or ":" in book_id:
raise HTTPException(status_code=400, detail="Invalid book_id")
def _parse_modules(body_modules: list[dict]) -> list[LearningModule]:
"""Parse raw module dicts into LearningModule objects (shared by init/replace)."""
modules: list[LearningModule] = []
for i, m in enumerate(body_modules):
kps_data = m.get("knowledge_points", [])
try:
kps = [KnowledgePoint(**kp) for kp in kps_data]
except PydanticValidationError as exc:
raise HTTPException(
status_code=422,
detail=f"Invalid knowledge_point data in modules[{i}]: {exc.errors()}",
) from exc
# Remove knowledge_points from m to avoid duplicate argument to LearningModule.
m_clean = {k: v for k, v in m.items() if k != "knowledge_points"}
try:
modules.append(LearningModule(knowledge_points=kps, **m_clean))
except PydanticValidationError as exc:
raise HTTPException(
status_code=422,
detail=f"Invalid module data in modules[{i}]: {exc.errors()}",
) from exc
return modules
def _validate_runnable_modules(modules: list[LearningModule], *, status_code: int = 400) -> None:
if not modules:
raise HTTPException(
status_code=status_code, detail="At least one learning module is required"
)
for mod in modules:
if not mod.knowledge_points:
raise HTTPException(
status_code=status_code,
detail=f"Module {mod.id!r} must contain at least one knowledge point",
)
async def _cancel_active_learning_turn(book_id: str) -> None:
from deeptutor.services.session import get_turn_runtime_manager
runtime = get_turn_runtime_manager()
active_turn = await runtime.store.get_active_turn(book_id)
if active_turn:
await runtime.cancel_turn(active_turn["id"])
# ── Request models ───────────────────────────────────────────────────────────
class InitModulesRequest(BaseModel):
modules: list[dict] # list of LearningModule-compatible dicts
class ChapterImport(BaseModel):
title: str
knowledge_points: list[str] = []
class ImportFromBookRequest(BaseModel):
chapters: list[ChapterImport]
# ── Endpoints ────────────────────────────────────────────────────────────────
@router.get("/progress")
async def list_all_progress():
service = get_learning_service()
return service.list_progress()
@router.get("/progress/{book_id}")
async def get_progress(book_id: str):
_validate_book_id(book_id)
service = get_learning_service()
progress = service.get_or_create(book_id)
return progress.model_dump()
@router.get("/progress/{book_id}/map")
async def get_progress_map(book_id: str):
"""The dashboard view of a path: the gate-decided next step plus a map of
every objective's status (new / learning / mastered). The per-type gate
lives in ``learning.policy`` so the dashboard and the tutor agree."""
_validate_book_id(book_id)
service = get_learning_service()
progress = service.get_or_create(book_id)
return {
"book_id": book_id,
"next": learning_policy.next_objective(progress).to_dict(),
"map": learning_policy.map_summary(progress),
}
@router.post("/progress/{book_id}/init-modules")
async def init_modules(book_id: str, body: InitModulesRequest):
_validate_book_id(book_id)
modules = _parse_modules(body.modules)
_validate_runnable_modules(modules)
await _cancel_active_learning_turn(book_id)
service = get_learning_service()
progress = service.get_or_create(book_id)
service.init_modules(progress, modules)
progress.current_module_id = modules[0].id
progress.current_kp_index = 0
service.save(progress)
return {"status": "ok", "module_count": len(modules)}
@router.post("/progress/{book_id}/import-from-book")
async def import_from_book(book_id: str, body: ImportFromBookRequest):
_validate_book_id(book_id)
modules = []
for i, ch in enumerate(body.chapters):
kps = [
KnowledgePoint(
id=f"{book_id}_ch{i}_kp{j}",
name=kp_name,
type=KnowledgeType("concept"),
module_id=f"{book_id}_ch{i}",
)
for j, kp_name in enumerate(ch.knowledge_points)
]
modules.append(
LearningModule(
id=f"{book_id}_ch{i}",
name=ch.title or f"Chapter {i + 1}",
order=i,
pass_threshold=0.7,
knowledge_points=kps,
)
)
_validate_runnable_modules(modules)
await _cancel_active_learning_turn(book_id)
service = get_learning_service()
progress = service.get_or_create(book_id)
service.init_modules(progress, modules)
progress.current_module_id = modules[0].id
progress.current_kp_index = 0
service.save(progress)
return {"status": "ok", "module_count": len(modules)}
@router.delete("/progress/{book_id}")
async def delete_progress(book_id: str):
_validate_book_id(book_id)
store = LearningStore()
if not store.exists(book_id):
raise HTTPException(status_code=404, detail="Progress not found")
store.delete(book_id)
return {"status": "ok"}
@router.post("/progress/{book_id}/redo")
async def redo_progress(book_id: str):
_validate_book_id(book_id)
store = LearningStore()
progress = store.load(book_id)
if progress is None:
raise HTTPException(status_code=404, detail="Progress not found")
progress.current_stage = LearningStage.DIAGNOSTIC
progress.mastery_levels = {}
progress.qualitative_mastery = {}
progress.quiz_attempts = []
progress.error_records = []
progress.repetition_states = {}
progress.review_queue = []
progress.pending_question = None
progress.feynman_retries = {}
progress.feynman_explanations = {}
progress.stage_failure_counts = {}
progress.stage_failure_notes = {}
progress.diagnostic = None
progress.current_kp_index = 0
progress.current_module_id = progress.modules[0].id if progress.modules else ""
store.save(progress)
return {"status": "ok"}
class NotebookRecordInput(BaseModel):
id: str
type: str = "note"
title: str = ""
output: str = ""
class GenerateFromNotebookRequest(BaseModel):
notebook_id: str
records: list[NotebookRecordInput]
@router.post("/progress/{book_id}/generate-from-notebook")
async def generate_from_notebook(book_id: str, body: GenerateFromNotebookRequest):
_validate_book_id(book_id)
if not body.records:
raise HTTPException(status_code=400, detail="No records provided")
records_data = [
{
"type": html.escape(r.type[:50], quote=False),
"title": html.escape(r.title[:200], quote=False),
"output": html.escape(r.output[:500], quote=False),
}
for r in body.records[:20]
]
records_json = json.dumps(records_data, ensure_ascii=False)
from deeptutor.services.llm import complete
language = get_ui_language()
system_prompt, prompt = learning_prompts.notebook_generation_prompts(language, records_json)
response = await complete(prompt=prompt, system_prompt=system_prompt)
# LLMs commonly fence/slightly-malform JSON; use the shared fence-stripping
# repair parser instead of bare json.loads so the common case isn't a 502.
data = parse_json_response(response, fallback=None)
if not isinstance(data, dict):
raise HTTPException(status_code=502, detail="LLM returned invalid JSON")
modules_raw = data.get("modules", [])
if not isinstance(modules_raw, list):
raise HTTPException(
status_code=502, detail="LLM returned invalid structure: modules is not a list"
)
_ALLOWED_KP_TYPES = {"memory", "concept", "procedure", "design"}
modules = []
for i, m in enumerate(modules_raw):
if not isinstance(m, dict) or "name" not in m:
continue
fallback_name = learning_prompts.default_module_name(language, i + 1)
module_name = str(m.get("name") or fallback_name).strip()[:200] or fallback_name
kps = []
for j, kp in enumerate(m.get("knowledge_points", [])):
if not isinstance(kp, dict) or "name" not in kp:
continue
kp_name = str(kp["name"]).strip()[:200]
if len(kp_name) < 2:
continue
kp_type = str(kp.get("type", "concept")).strip()
if kp_type not in _ALLOWED_KP_TYPES:
kp_type = "concept"
kps.append(
KnowledgePoint(
id=f"{book_id}_nb{i}_kp{j}",
name=kp_name,
type=KnowledgeType(kp_type),
module_id=f"{book_id}_nb{i}",
)
)
modules.append(
LearningModule(
id=f"{book_id}_nb{i}",
name=module_name,
order=i,
pass_threshold=0.7,
knowledge_points=kps,
)
)
_validate_runnable_modules(modules, status_code=502)
await _cancel_active_learning_turn(book_id)
service = get_learning_service()
progress = service.get_or_create(book_id)
service.init_modules(progress, modules)
progress.current_module_id = modules[0].id
progress.current_kp_index = 0
service.save(progress)
return {
"status": "ok",
"module_count": len(modules),
"modules": [m.model_dump() for m in modules],
}
+93
View File
@@ -0,0 +1,93 @@
"""
MCP Settings API Router
=======================
Manage the deployment-global MCP server registry: read/update the config,
inspect live connection status, and probe a server before saving.
Mounted at ``/api/v1/settings/mcp``. Admin-gated: the registry is
deployment-global state, and a stdio server's ``command`` runs on the host
as the app user — letting non-admins edit it would be privilege escalation.
Per-user MCP access is granted through the multi-user grant whitelist
(``mcp_tools``), not by sharing this registry.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, ValidationError
from deeptutor.api.routers.auth import require_admin
from deeptutor.core.i18n import t
from deeptutor.services.mcp import (
MCPConfig,
MCPServerConfig,
get_mcp_manager,
load_mcp_config,
save_mcp_config,
validate_mcp_url,
)
from deeptutor.services.mcp.manager import probe_server
router = APIRouter(dependencies=[Depends(require_admin)])
class MCPSettingsPayload(BaseModel):
servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
def _validate_servers(config: MCPConfig) -> None:
for name, cfg in config.servers.items():
transport = cfg.resolved_type()
if transport is None:
raise HTTPException(
status_code=400,
detail=t("mcp.configure_command_or_url", name=name),
)
if transport in {"sse", "streamableHttp"}:
ok, error = validate_mcp_url(cfg.url)
if not ok:
raise HTTPException(
status_code=400, detail=t("mcp.server_error", name=name, error=error)
)
@router.get("")
async def get_mcp_settings() -> dict[str, Any]:
config = load_mcp_config()
manager = get_mcp_manager()
await manager.ensure_started()
return {
"servers": {name: cfg.model_dump(mode="json") for name, cfg in config.servers.items()},
"status": manager.status(),
}
@router.put("")
async def update_mcp_settings(payload: MCPSettingsPayload) -> dict[str, Any]:
try:
config = MCPConfig(servers=payload.servers)
except (ValidationError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
_validate_servers(config)
save_mcp_config(config)
manager = get_mcp_manager()
await manager.reload()
return {"status": manager.status()}
@router.post("/test")
async def test_mcp_server(cfg: MCPServerConfig) -> dict[str, Any]:
transport = cfg.resolved_type()
if transport is None:
raise HTTPException(
status_code=400,
detail=t("mcp.configure_before_testing"),
)
if transport in {"sse", "streamableHttp"}:
ok, error = validate_mcp_url(cfg.url)
if not ok:
raise HTTPException(status_code=400, detail=error)
return await probe_server(cfg)
+785
View File
@@ -0,0 +1,785 @@
"""Memory v3 API — workbench backend.
Three layers, three modes (update / audit / dedup). Long-running work
is owned by the :mod:`runs` manager so a refresh / nav-away does not
kill it; clients re-attach by polling ``/runs/{id}/events?since=N``.
- ``GET /overview`` → all 11 docs' state + L1 backlog
- ``GET /doc/{layer}/{key}`` → raw MD
- ``GET /doc/{layer}/{key}/lines`` → line-numbered view
- ``PUT /doc/{layer}/{key}`` → user-edited save
- ``DELETE /doc/{layer}/{key}/entry/{id}`` → drop one entry
- ``POST /runs/start`` → start update/audit/dedup; returns run_id
- ``GET /runs/{id}`` → run state
- ``GET /runs/{id}/events?since=N`` → SSE-replay events from cursor N
- ``POST /runs/{id}/cancel`` → cooperative cancellation
- ``POST /runs/{id}/undo`` → restore latest run write
- ``GET /runs?layer=L2&key=chat`` → active+recent runs for one doc
- ``GET /settings`` → memory: settings subtree
- ``PUT /settings`` → save memory: settings subtree
- ``GET /trace/{surface}`` → paginated L1 events
- ``DELETE /trace/{surface}/day/{date}`` → drop one day of trace
- ``DELETE /trace/{surface}`` → drop all trace for a surface
- ``GET /backup`` → list v1-migration backup dirs (if any)
The legacy per-mode endpoints (``POST /doc/{layer}/{key}/update`` etc.)
are kept for the moment as thin wrappers that start a run and stream
its events — older clients keep working.
"""
from __future__ import annotations
from dataclasses import asdict
from datetime import date as date_cls
import json
import logging
import re
from typing import Literal
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from deeptutor.services.memory import (
L3_SLOTS,
SURFACES,
Surface,
get_memory_store,
paths,
)
_ENTRY_ID_RE = re.compile(r"^m_[0-9A-HJKMNP-TV-Z]{26}$")
logger = logging.getLogger(__name__)
router = APIRouter()
Layer = Literal["L2", "L3"]
# ── Helpers ──────────────────────────────────────────────────────────────
def _validate_doc_key(layer: Layer, key: str) -> None:
if layer == "L2" and key not in SURFACES:
raise HTTPException(status_code=404, detail=f"unknown surface {key!r}")
if layer == "L3" and key not in L3_SLOTS:
raise HTTPException(status_code=404, detail=f"unknown L3 slot {key!r}")
def _validate_layer(layer: str) -> Layer:
if layer not in {"L2", "L3"}:
raise HTTPException(status_code=400, detail="layer must be L2 or L3")
return layer # type: ignore[return-value]
def _validate_surface(surface: str) -> Surface:
if surface not in SURFACES:
raise HTTPException(status_code=404, detail=f"unknown surface {surface!r}")
return surface # type: ignore[return-value]
# ── Overview / list ──────────────────────────────────────────────────────
@router.get("/overview")
async def get_overview():
store = get_memory_store()
rows = [asdict(r) for r in store.overview()]
backup_dir = paths.backup_root()
backups: list[str] = []
if backup_dir.exists():
backups = sorted(p.name for p in backup_dir.iterdir() if p.is_dir())
return {"docs": rows, "backups": backups}
@router.get("/resolve_entry/{entry_id}")
async def resolve_entry(entry_id: str):
"""Find which L2 doc owns this entry id.
L3 docs cite L2 entries by their ``m_<ULID>`` entry id; the workbench
UI uses this resolver to turn an L3 footnote click into a navigation
to the right L2 surface + scroll-to anchor.
Scans the seven L2 mds in order; first hit wins. 404 if no L2 doc
contains the id (e.g. the entry was deleted or the id is stale).
"""
if not _ENTRY_ID_RE.match(entry_id):
raise HTTPException(status_code=400, detail="not a valid entry id")
from deeptutor.services.memory.document import parse
for surface in SURFACES:
path = paths.l2_file(surface)
if not path.exists():
continue
try:
doc = parse(path.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001 — malformed L2 should not 500 the resolver
continue
for entry in doc.all_entries():
if entry.id == entry_id:
return {"layer": "L2", "key": surface, "entry_id": entry_id}
raise HTTPException(status_code=404, detail="entry not found in any L2 doc")
@router.get("/backup")
async def list_backups():
backup_dir = paths.backup_root()
if not backup_dir.exists():
return {"backups": []}
out: list[dict] = []
for entry in sorted(backup_dir.iterdir()):
if entry.is_dir():
files = sorted(p.name for p in entry.iterdir())
out.append({"name": entry.name, "files": files})
return {"backups": out}
# ── Doc read / write / delete ────────────────────────────────────────────
@router.get("/doc/{layer}/{key}")
async def get_doc(layer: str, key: str):
lyr = _validate_layer(layer)
_validate_doc_key(lyr, key)
return {"layer": lyr, "key": key, "content": get_memory_store().read_raw(lyr, key)}
class DocWriteRequest(BaseModel):
content: str
@router.put("/doc/{layer}/{key}")
async def put_doc(layer: str, key: str, payload: DocWriteRequest):
lyr = _validate_layer(layer)
_validate_doc_key(lyr, key)
await get_memory_store().overwrite_doc(lyr, key, payload.content)
return {"layer": lyr, "key": key, "saved": True}
@router.delete("/doc/{layer}/{key}/entry/{entry_id}")
async def delete_entry(layer: str, key: str, entry_id: str):
lyr = _validate_layer(layer)
_validate_doc_key(lyr, key)
ok = await get_memory_store().delete_entry(lyr, key, entry_id)
if not ok:
raise HTTPException(status_code=404, detail="entry not found")
return {"layer": lyr, "key": key, "deleted": entry_id}
@router.post("/doc/{layer}/{key}/reset")
async def reset_doc(layer: str, key: str):
"""Wipe the doc + its meta sidecar so the next update starts fresh.
Destructive — the caller has confirmed. After this returns the .md
file is gone *and* the ``seen_entity_refs`` set is cleared, so a
subsequent ``run_update`` re-ingests every L1 entity instead of
treating them as already-seen.
Refuses while a consolidator run is active on this doc; the caller
can cancel first.
"""
lyr = _validate_layer(layer)
_validate_doc_key(lyr, key)
from deeptutor.services.memory.consolidator.runs import get_run_manager
if get_run_manager().active_for(lyr, key) is not None:
raise HTTPException(
status_code=409,
detail="cancel the active run before resetting this doc",
)
from deeptutor.services.memory.consolidator import meta as meta_mod
doc_path = paths.l2_file(key) if lyr == "L2" else paths.l3_file(key) # type: ignore[arg-type]
meta_path = (
meta_mod.l2_meta_path(key) # type: ignore[arg-type]
if lyr == "L2"
else meta_mod.l3_meta_path(key) # type: ignore[arg-type]
)
removed_doc = False
removed_meta = False
try:
if doc_path.exists():
doc_path.unlink()
removed_doc = True
if meta_path.exists():
meta_path.unlink()
removed_meta = True
except OSError as exc:
raise HTTPException(status_code=500, detail=f"reset failed: {exc}") from exc
return {
"layer": lyr,
"key": key,
"reset": True,
"removed_doc": removed_doc,
"removed_meta": removed_meta,
}
# ── Doc update (SSE-streamed consolidator) ───────────────────────────────
class LLMSelectionPayload(BaseModel):
profile_id: str
model_id: str
class RunStartRequest(BaseModel):
layer: str
key: str
mode: Literal["update", "audit", "dedup", "merge"]
language: str = "en"
budget: int | None = None
iterations: int | None = None
llm_selection: LLMSelectionPayload | None = None
def _runner_for(req: RunStartRequest):
"""Return an ``async on_event → None`` runner for the requested mode."""
from deeptutor.services.memory.consolidator import (
run_audit,
run_dedup,
run_merge,
run_update,
)
selection = (
{"profile_id": req.llm_selection.profile_id, "model_id": req.llm_selection.model_id}
if req.llm_selection
else None
)
if req.mode == "update":
async def go(on_event):
await run_update(
req.layer,
req.key,
language=req.language,
budget=req.budget,
llm_selection=selection,
on_event=on_event,
)
return go
if req.mode == "audit":
async def go(on_event):
await run_audit(
req.layer,
req.key,
language=req.language,
budget=req.budget,
llm_selection=selection,
on_event=on_event,
)
return go
if req.mode == "dedup":
async def go(on_event):
await run_dedup(
req.layer,
req.key,
language=req.language,
iterations=req.iterations,
llm_selection=selection,
on_event=on_event,
)
return go
if req.mode == "merge":
async def go(on_event):
await run_merge(
req.layer,
req.key,
language=req.language,
on_event=on_event,
)
return go
raise HTTPException(status_code=400, detail=f"unknown mode {req.mode!r}")
@router.post("/runs/start")
async def start_run(req: RunStartRequest):
"""Start one consolidator mode and return a run handle.
The run survives client disconnects; reconnect via
``GET /runs/{id}/events?since=N``.
"""
lyr = _validate_layer(req.layer)
_validate_doc_key(lyr, req.key)
if lyr == "L3" and req.key == "preferences" and req.mode not in ("dedup", "merge"):
raise HTTPException(
status_code=405,
detail="preferences is written by the write_memory tool, not consolidated",
)
from deeptutor.services.memory.consolidator.runs import (
RunBusyError,
get_run_manager,
)
manager = get_run_manager()
runner = _runner_for(req)
selection = (
{"profile_id": req.llm_selection.profile_id, "model_id": req.llm_selection.model_id}
if req.llm_selection
else None
)
try:
run = await manager.start(
layer=lyr,
key=req.key,
mode=req.mode,
runner=runner,
params={
"budget": req.budget,
"iterations": req.iterations,
"language": req.language,
"llm_selection": selection,
},
language=req.language,
)
except RunBusyError as exc:
raise HTTPException(status_code=409, detail=str(exc))
return run.to_dict()
@router.get("/runs/{run_id}")
async def get_run(run_id: str):
from deeptutor.services.memory.consolidator.runs import get_run_manager
run = get_run_manager().get(run_id)
if run is None:
raise HTTPException(status_code=404, detail="unknown run_id")
return run.to_dict()
@router.post("/runs/{run_id}/cancel")
async def cancel_run(run_id: str):
from deeptutor.services.memory.consolidator.runs import get_run_manager
ok = await get_run_manager().cancel(run_id)
if not ok:
raise HTTPException(status_code=409, detail="not active")
return {"run_id": run_id, "cancelled": True}
@router.post("/runs/{run_id}/undo")
async def undo_run_edit(run_id: str):
from deeptutor.services.memory.consolidator.runs import (
RunBusyError,
get_run_manager,
)
manager = get_run_manager()
try:
event = await manager.undo_last(run_id)
except KeyError:
raise HTTPException(status_code=404, detail="unknown run_id")
except RunBusyError as exc:
raise HTTPException(status_code=409, detail=str(exc))
if event is None:
raise HTTPException(status_code=409, detail="nothing to undo")
run = manager.get(run_id)
return {
"run_id": run_id,
"undone": True,
"undo_count": len(run.undo_stack) if run else 0,
"event": {"seq": event.seq, "ts": event.ts, **event.payload},
}
@router.get("/runs")
async def list_runs(layer: str | None = None, key: str | None = None):
from deeptutor.services.memory.consolidator.runs import get_run_manager
lyr = _validate_layer(layer) if layer is not None else None
if lyr and key is not None:
_validate_doc_key(lyr, key)
runs = get_run_manager().list_for(layer=lyr, key=key)
return {"runs": [r.to_dict() for r in runs]}
@router.get("/runs/{run_id}/events")
async def stream_run_events(run_id: str, since: int = 0):
"""SSE-replay events from ``since`` (exclusive) until the run ends.
Reconnecting after a refresh: pass the largest ``seq`` previously
observed. The manager replays the buffered tail, then blocks on
new events until the run reaches a terminal state.
"""
from deeptutor.services.memory.consolidator.runs import get_run_manager
manager = get_run_manager()
run = manager.get(run_id)
if run is None:
raise HTTPException(status_code=404, detail="unknown run_id")
async def producer():
cursor = max(0, since)
# Initial backfill (if any) is delivered as a batch up front.
while True:
events = await manager.wait_for_events(run, since=cursor)
for ev in events:
yield (
"data: "
+ json.dumps(
{"seq": ev.seq, "ts": ev.ts, **ev.payload},
ensure_ascii=False,
)
+ "\n\n"
)
cursor = max(cursor, run.events[-1].seq + 1 if run.events else cursor)
if not run.active:
# Drain any final events that arrived between wait return and now.
final = run.events[cursor:]
for ev in final:
yield (
"data: "
+ json.dumps(
{"seq": ev.seq, "ts": ev.ts, **ev.payload},
ensure_ascii=False,
)
+ "\n\n"
)
break
return StreamingResponse(producer(), media_type="text/event-stream")
# ── Legacy per-mode endpoints (kept as thin wrappers over /runs/start) ──
def _legacy_run_stream(req: RunStartRequest) -> StreamingResponse:
"""Old contract: POST /doc/{layer}/{key}/<mode> streams events inline."""
from deeptutor.services.memory.consolidator.runs import (
RunBusyError,
get_run_manager,
)
async def producer():
manager = get_run_manager()
runner = _runner_for(req)
selection = (
{"profile_id": req.llm_selection.profile_id, "model_id": req.llm_selection.model_id}
if req.llm_selection
else None
)
try:
run = await manager.start(
layer=req.layer,
key=req.key,
mode=req.mode,
runner=runner,
params={
"budget": req.budget,
"iterations": req.iterations,
"language": req.language,
"llm_selection": selection,
},
language=req.language,
)
except RunBusyError as exc:
yield (
"data: "
+ json.dumps({"stage": "error", "message": str(exc)}, ensure_ascii=False)
+ "\n\n"
)
return
cursor = 0
while True:
events = await manager.wait_for_events(run, since=cursor)
for ev in events:
yield (
"data: "
+ json.dumps({**ev.payload, "seq": ev.seq}, ensure_ascii=False)
+ "\n\n"
)
cursor = ev.seq + 1
if not run.active:
break
return StreamingResponse(producer(), media_type="text/event-stream")
class UpdateRequest(BaseModel):
language: str = "en"
budget: int | None = None
llm_selection: LLMSelectionPayload | None = None
class AuditRequest(BaseModel):
language: str = "en"
budget: int | None = None
llm_selection: LLMSelectionPayload | None = None
class DedupRequest(BaseModel):
language: str = "en"
iterations: int | None = None
llm_selection: LLMSelectionPayload | None = None
@router.post("/doc/{layer}/{key}/update")
async def update_doc(layer: str, key: str, payload: UpdateRequest | None = None):
lyr = _validate_layer(layer)
_validate_doc_key(lyr, key)
req = RunStartRequest(
layer=lyr,
key=key,
mode="update",
language=(payload.language if payload else "en") or "en",
budget=payload.budget if payload else None,
llm_selection=payload.llm_selection if payload else None,
)
return _legacy_run_stream(req)
@router.post("/doc/{layer}/{key}/audit")
async def audit_doc(layer: str, key: str, payload: AuditRequest | None = None):
lyr = _validate_layer(layer)
_validate_doc_key(lyr, key)
req = RunStartRequest(
layer=lyr,
key=key,
mode="audit",
language=(payload.language if payload else "en") or "en",
budget=payload.budget if payload else None,
llm_selection=payload.llm_selection if payload else None,
)
return _legacy_run_stream(req)
@router.post("/doc/{layer}/{key}/dedup")
async def dedup_doc(layer: str, key: str, payload: DedupRequest | None = None):
lyr = _validate_layer(layer)
_validate_doc_key(lyr, key)
req = RunStartRequest(
layer=lyr,
key=key,
mode="dedup",
language=(payload.language if payload else "en") or "en",
iterations=payload.iterations if payload else None,
llm_selection=payload.llm_selection if payload else None,
)
return _legacy_run_stream(req)
@router.get("/doc/{layer}/{key}/lines")
async def get_doc_lines(layer: str, key: str):
"""Return the line-numbered, footnote-stripped view of a doc.
Used by the workbench's "show line numbers" toggle so the same line
indices the audit/dedup LLMs see are visible to the user. Footnote
block is omitted because edit ops never reference it directly.
"""
lyr = _validate_layer(layer)
_validate_doc_key(lyr, key)
from deeptutor.services.memory import paths
from deeptutor.services.memory.consolidator.line_doc import render_view
from deeptutor.services.memory.document import Document, parse
path = paths.l2_file(key) if lyr == "L2" else paths.l3_file(key) # type: ignore[arg-type]
doc = (
parse(path.read_text(encoding="utf-8"))
if path.exists()
else Document(title=_default_title(lyr, key))
)
view = render_view(doc)
return {
"layer": lyr,
"key": key,
"lines": [
{
"number": line.number,
"kind": line.kind,
"text": line.text,
"entry_id": line.entry_id,
"section": line.section,
}
for line in view.lines
],
}
# ── Settings ────────────────────────────────────────────────────────────
@router.get("/settings")
async def get_memory_settings_endpoint():
"""Return the current ``memory:`` subtree (defaults merged in)."""
from deeptutor.services.memory.settings import memory_settings_dict
return memory_settings_dict()
@router.put("/settings")
async def put_memory_settings(payload: dict):
"""Merge the payload into the ``memory:`` subtree and persist."""
from deeptutor.services.memory.settings import (
memory_settings_dict,
save_memory_settings,
)
save_memory_settings(payload)
return memory_settings_dict()
def _default_title(layer: str, key: str) -> str:
if layer == "L2":
return f"{key} memory"
return {
"recent": "Recent summary",
"profile": "User profile",
"scope": "Knowledge scope",
"preferences": "Preferences",
}.get(key, f"{key} memory")
class ApplyOpsRequest(BaseModel):
ops: list[dict]
@router.post("/doc/{layer}/{key}/apply")
async def apply_doc_ops(layer: str, key: str, payload: ApplyOpsRequest):
"""Commit a list of previously-previewed ops to a doc atomically."""
lyr = _validate_layer(layer)
_validate_doc_key(lyr, key)
if lyr == "L3" and key == "preferences":
raise HTTPException(
status_code=405,
detail="preferences is written by the write_memory tool, not consolidated",
)
if not payload.ops:
return {"accepted": True, "reason": "no ops to apply", "results": []}
report = await get_memory_store().apply_ops_payload(lyr, key, payload.ops)
return {
"accepted": report.accepted,
"reason": report.reason,
"results": [
{
"status": r.status,
"entry_id": r.entry_id,
"detail": r.detail,
}
for r in report.results
],
}
# ── Trace browser ────────────────────────────────────────────────────────
@router.get("/trace/{surface}")
async def get_trace(surface: str, limit: int = 200, offset: int = 0):
surf = _validate_surface(surface)
from deeptutor.services.memory.trace import iter_since
events = []
for i, event in enumerate(iter_since(surf)):
if i < offset:
continue
if len(events) >= max(1, min(limit, 1000)):
break
events.append(asdict(event))
return {"surface": surf, "events": events, "offset": offset, "limit": limit}
@router.delete("/trace/{surface}")
async def clear_trace(surface: str):
surf = _validate_surface(surface)
removed = 0
for path in paths.trace_dir(surf).glob("*.jsonl"):
try:
path.unlink()
removed += 1
except OSError:
continue
return {"surface": surf, "removed_files": removed}
@router.delete("/trace/{surface}/day/{day}")
async def clear_trace_day(surface: str, day: str):
surf = _validate_surface(surface)
try:
parsed = date_cls.fromisoformat(day)
except ValueError:
raise HTTPException(status_code=400, detail="day must be YYYY-MM-DD")
path = paths.trace_file(surf, parsed)
if not path.exists():
raise HTTPException(status_code=404, detail="no trace for that day")
try:
path.unlink()
except OSError as exc:
raise HTTPException(status_code=500, detail=str(exc))
return {"surface": surf, "day": day, "deleted": True}
# ── Snapshot (L1 workspace mirror) ───────────────────────────────────────
@router.get("/snapshot/{surface}")
async def get_snapshot(surface: str):
"""Return the current entity list for ``surface`` from workspace.
Snapshot is always derived live from workspace at call time. The response
also includes ``pending_changes`` — the diff vs the last persisted state.
Refresh commits these pending changes into ``changes.jsonl``.
"""
surf = _validate_surface(surface)
from deeptutor.services.memory import snapshot as snap
entities = snap.read_snapshot(surf)
pending = snap.pending_changes(surf, entities)
state = snap.current_state(surf)
return {
"surface": surf,
"entities": [e.to_dict() for e in entities],
"last_refresh": state.get("last_refresh"),
"pending_changes": [c.to_dict() for c in pending],
}
@router.post("/snapshot/{surface}/refresh")
async def refresh_snapshot(surface: str):
"""Reconcile persisted state with current workspace; record diffs."""
surf = _validate_surface(surface)
from deeptutor.services.memory import snapshot as snap
changes = snap.refresh_snapshot(surf)
state = snap.current_state(surf)
return {
"surface": surf,
"changes": [c.to_dict() for c in changes],
"last_refresh": state.get("last_refresh"),
}
@router.get("/snapshot/{surface}/changes")
async def get_changes(surface: str, limit: int = 200, offset: int = 0):
surf = _validate_surface(surface)
from deeptutor.services.memory import snapshot as snap
entries = snap.read_changes(surf, limit=limit, offset=offset)
return {
"surface": surf,
"changes": [c.to_dict() for c in entries],
"limit": limit,
"offset": offset,
}
@router.delete("/snapshot/{surface}/changes")
async def clear_snapshot_changes(surface: str):
surf = _validate_surface(surface)
from deeptutor.services.memory import snapshot as snap
snap.clear_changes(surf)
return {"surface": surf, "cleared": True}
+358
View File
@@ -0,0 +1,358 @@
"""
Notebook API Router
Provides notebook creation, querying, updating, deletion, and record management functions
"""
import json
from typing import AsyncGenerator, Literal
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from deeptutor.agents.notebook import NotebookSummarizeAgent
from deeptutor.services.llm import clean_thinking_tags
from deeptutor.services.notebook import notebook_manager
router = APIRouter()
# === Request/Response Models ===
class CreateNotebookRequest(BaseModel):
"""Create notebook request"""
name: str
description: str = ""
color: str = "#3B82F6"
icon: str = "book"
class UpdateNotebookRequest(BaseModel):
"""Update notebook request"""
name: str | None = None
description: str | None = None
color: str | None = None
icon: str | None = None
class AddRecordRequest(BaseModel):
"""Add record request"""
notebook_ids: list[str]
record_type: Literal["solve", "question", "research", "chat", "co_writer", "tutorbot"]
title: str
summary: str = ""
user_query: str
output: str
metadata: dict = {}
kb_name: str | None = None
class RemoveRecordRequest(BaseModel):
"""Remove record request"""
record_id: str
class UpdateRecordRequest(BaseModel):
"""Update an existing notebook record."""
title: str | None = None
summary: str | None = None
user_query: str | None = None
output: str | None = None
metadata: dict | None = None
kb_name: str | None = None
# === API Endpoints ===
async def _build_record_summary(request: AddRecordRequest) -> str:
if request.summary.strip():
return clean_thinking_tags(request.summary).strip()
agent = NotebookSummarizeAgent(language=str(request.metadata.get("ui_language", "en")))
return clean_thinking_tags(
await agent.summarize(
title=request.title,
record_type=request.record_type,
user_query=request.user_query,
output=request.output,
metadata=request.metadata,
)
).strip()
async def _stream_add_record_with_summary(
request: AddRecordRequest,
) -> AsyncGenerator[str, None]:
try:
agent = NotebookSummarizeAgent(language=str(request.metadata.get("ui_language", "en")))
summary_parts: list[str] = []
if request.summary.strip():
summary = clean_thinking_tags(request.summary).strip()
summary_parts.append(summary)
if summary:
yield f"data: {json.dumps({'type': 'summary_chunk', 'content': summary}, ensure_ascii=False)}\n\n"
else:
async for chunk in agent.stream_summary(
title=request.title,
record_type=request.record_type,
user_query=request.user_query,
output=request.output,
metadata=request.metadata,
):
if not chunk:
continue
summary_parts.append(chunk)
summary = clean_thinking_tags("".join(summary_parts)).strip()
if summary:
yield f"data: {json.dumps({'type': 'summary_chunk', 'content': summary}, ensure_ascii=False)}\n\n"
summary = clean_thinking_tags("".join(summary_parts)).strip()
result = notebook_manager.add_record(
notebook_ids=request.notebook_ids,
record_type=request.record_type,
title=request.title,
summary=summary,
user_query=request.user_query,
output=request.output,
metadata=request.metadata,
kb_name=request.kb_name,
)
payload = {
"type": "result",
"success": True,
"summary": summary,
"record": result["record"],
"added_to_notebooks": result["added_to_notebooks"],
}
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
except Exception as exc:
payload = {"type": "error", "detail": str(exc)}
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
@router.get("/list")
async def list_notebooks():
"""
Get all notebook list
Returns:
Notebook list (includes summary information)
"""
try:
notebooks = notebook_manager.list_notebooks()
return {"notebooks": notebooks, "total": len(notebooks)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/statistics")
async def get_statistics():
"""
Get notebook statistics
Returns:
Statistics information
"""
try:
stats = notebook_manager.get_statistics()
return stats
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/create")
async def create_notebook(request: CreateNotebookRequest):
"""
Create new notebook
Args:
request: Create request
Returns:
Created notebook information
"""
try:
notebook = notebook_manager.create_notebook(
name=request.name,
description=request.description,
color=request.color,
icon=request.icon,
)
return {"success": True, "notebook": notebook}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{notebook_id}")
async def get_notebook(notebook_id: str):
"""
Get notebook details
Args:
notebook_id: Notebook ID
Returns:
Notebook details (includes all records)
"""
try:
notebook = notebook_manager.get_notebook(notebook_id)
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
return notebook
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{notebook_id}")
async def update_notebook(notebook_id: str, request: UpdateNotebookRequest):
"""
Update notebook information
Args:
notebook_id: Notebook ID
request: Update request
Returns:
Updated notebook information
"""
try:
notebook = notebook_manager.update_notebook(
notebook_id=notebook_id,
name=request.name,
description=request.description,
color=request.color,
icon=request.icon,
)
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
return {"success": True, "notebook": notebook}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{notebook_id}")
async def delete_notebook(notebook_id: str):
"""
Delete notebook
Args:
notebook_id: Notebook ID
Returns:
Deletion result
"""
try:
success = notebook_manager.delete_notebook(notebook_id)
if not success:
raise HTTPException(status_code=404, detail="Notebook not found")
return {"success": True, "message": "Notebook deleted successfully"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/add_record")
async def add_record(request: AddRecordRequest):
"""
Add record to notebook
Args:
request: Add record request
Returns:
Addition result
"""
try:
summary = await _build_record_summary(request)
result = notebook_manager.add_record(
notebook_ids=request.notebook_ids,
record_type=request.record_type,
title=request.title,
summary=summary,
user_query=request.user_query,
output=request.output,
metadata=request.metadata,
kb_name=request.kb_name,
)
return {
"success": True,
"summary": summary,
"record": result["record"],
"added_to_notebooks": result["added_to_notebooks"],
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/add_record_with_summary")
async def add_record_with_summary(request: AddRecordRequest):
"""Add record to notebook and stream generated summary."""
return StreamingResponse(
_stream_add_record_with_summary(request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.delete("/{notebook_id}/records/{record_id}")
async def remove_record(notebook_id: str, record_id: str):
"""
Remove record from notebook
Args:
notebook_id: Notebook ID
record_id: Record ID
Returns:
Deletion result
"""
try:
success = notebook_manager.remove_record(notebook_id, record_id)
if not success:
raise HTTPException(status_code=404, detail="Record not found")
return {"success": True, "message": "Record removed successfully"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{notebook_id}/records/{record_id}")
async def update_record(notebook_id: str, record_id: str, request: UpdateRecordRequest):
"""Update an existing notebook record in place."""
try:
updated = notebook_manager.update_record(
notebook_id=notebook_id,
record_id=record_id,
title=request.title,
summary=request.summary,
user_query=request.user_query,
output=request.output,
metadata=request.metadata,
kb_name=request.kb_name,
)
if not updated:
raise HTTPException(status_code=404, detail="Record not found")
return {"success": True, "record": updated}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/health")
async def health_check():
"""Health check"""
return {"status": "healthy", "service": "notebook"}
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
"""
Personas API Router
===================
CRUD endpoints for user-authored PERSONA.md files stored under
``data/user/workspace/personas/<name>/PERSONA.md``.
Personas are behaviour/voice presets, not capability skills: admin-authored
personas are visible to every user as read-only deployment presets (no grant
mechanism — a persona carries no privileged workflow, only style guidance).
Users create and manage their own personas in their own workspace; a user
persona shadows an admin persona of the same name.
Mounted at ``/api/v1/personas``.
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from deeptutor.core.i18n import t
from deeptutor.multi_user.context import get_current_user
from deeptutor.multi_user.paths import get_admin_path_service
from deeptutor.services.persona import (
InvalidPersonaNameError,
PersonaExistsError,
PersonaNotFoundError,
PersonaService,
get_persona_service,
)
router = APIRouter()
class CreatePersonaRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
description: str = ""
content: str = ""
class UpdatePersonaRequest(BaseModel):
description: str | None = None
content: str | None = None
rename_to: str | None = None
def _admin_persona_service() -> PersonaService:
return PersonaService(root=get_admin_path_service().get_workspace_dir() / "personas")
@router.get("/list")
async def list_personas() -> dict[str, list[dict[str, object]]]:
service = get_persona_service()
own = [info.to_dict() for info in service.list_personas()]
user = get_current_user()
if user.is_admin:
return {"personas": own}
own_names = {item["name"] for item in own}
merged = list(own)
for preset in _admin_persona_service().list_personas():
if preset.name in own_names:
continue
entry = preset.to_dict()
entry.update({"source": "admin", "read_only": True})
merged.append(entry)
return {"personas": merged}
@router.get("/{name}")
async def get_persona(name: str) -> dict[str, object]:
service = get_persona_service()
try:
return service.get_detail(name).to_dict()
except PersonaNotFoundError:
pass
except InvalidPersonaNameError as exc:
raise HTTPException(status_code=400, detail=str(exc))
user = get_current_user()
if not user.is_admin:
try:
detail = _admin_persona_service().get_detail(name).to_dict()
detail.update({"source": "admin", "read_only": True})
return detail
except (PersonaNotFoundError, InvalidPersonaNameError):
pass
raise HTTPException(status_code=404, detail=t("api.persona_not_found", name=name))
@router.post("/create")
async def create_persona(payload: CreatePersonaRequest) -> dict[str, object]:
service = get_persona_service()
try:
info = service.create(
name=payload.name,
description=payload.description,
content=payload.content,
)
return info.to_dict()
except PersonaExistsError:
raise HTTPException(
status_code=409,
detail=t("api.persona_already_exists", name=payload.name),
)
except InvalidPersonaNameError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.put("/{name}")
async def update_persona(name: str, payload: UpdatePersonaRequest) -> dict[str, object]:
service = get_persona_service()
try:
info = service.update(
name,
description=payload.description,
content=payload.content,
rename_to=payload.rename_to,
)
return info.to_dict()
except PersonaNotFoundError:
raise HTTPException(status_code=404, detail=t("api.persona_not_found", name=name))
except PersonaExistsError as exc:
raise HTTPException(status_code=409, detail=str(exc))
except InvalidPersonaNameError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.delete("/{name}")
async def delete_persona(name: str) -> dict[str, str]:
service = get_persona_service()
try:
service.delete(name)
return {"status": "deleted", "name": name}
except PersonaNotFoundError:
raise HTTPException(status_code=404, detail=t("api.persona_not_found", name=name))
except InvalidPersonaNameError as exc:
raise HTTPException(status_code=400, detail=str(exc))
+432
View File
@@ -0,0 +1,432 @@
"""
Plugins API Router
==================
Lists registered tools, capabilities, and playground plugins.
Provides direct tool execution for the Playground tester.
"""
import asyncio
import contextlib
import json
import logging
import re
import time
from typing import Any, AsyncGenerator
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, Field
from deeptutor.core.i18n import t
from deeptutor.i18n.metadata_i18n import tool_description_i18n
from deeptutor.logging import (
ProcessLogEvent,
bind_log_context,
capture_process_logs,
current_log_context,
)
from deeptutor.runtime.registry.capability_registry import get_capability_registry
from deeptutor.runtime.registry.tool_registry import get_tool_registry
logger = logging.getLogger(__name__)
router = APIRouter()
ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
def _discover_plugins() -> list[Any]:
try:
from deeptutor.plugins.loader import discover_plugins
except Exception:
logger.debug("Plugin loader unavailable; returning no plugins.", exc_info=True)
return []
return discover_plugins()
class ToolExecuteRequest(BaseModel):
params: dict[str, Any] = Field(default_factory=dict)
class CapabilityExecuteRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)
content: str
tools: list[str] = Field(default_factory=list, alias="enabledTools")
knowledge_bases: list[str] = Field(default_factory=list, alias="knowledgeBases")
language: str = "en"
config: dict[str, Any] = Field(default_factory=dict)
attachments: list[dict[str, Any]] = Field(default_factory=list)
# ``bot_id`` is the legacy TutorBot field name; it now addresses a partner.
partner_id: str | None = Field(default=None, alias="bot_id")
session_id: str | None = None
chat_id: str | None = None
llm_selection: dict[str, str] | None = Field(default=None, alias="llmSelection")
@router.get("/list")
async def list_plugins():
tool_registry = get_tool_registry()
capability_registry = get_capability_registry()
plugin_manifests = _discover_plugins()
tools = [
{
"name": definition.name,
"description": definition.description,
"description_i18n": tool_description_i18n(definition.name, definition.description),
"parameters": [
{
"name": parameter.name,
"type": parameter.type,
"description": parameter.description,
"required": parameter.required,
"default": parameter.default,
"enum": parameter.enum,
}
for parameter in definition.parameters
],
}
for definition in tool_registry.get_definitions()
]
capabilities = capability_registry.get_manifests()
plugins = [
{
"name": plugin.name,
"type": plugin.type,
"description": plugin.description,
"stages": plugin.stages,
"version": plugin.version,
"author": plugin.author,
}
for plugin in plugin_manifests
]
return {
"tools": tools,
"capabilities": capabilities,
"plugins": plugins,
}
@router.post("/tools/{tool_name}/execute")
async def execute_tool(tool_name: str, body: ToolExecuteRequest):
"""Execute a single tool with explicit parameters (for Playground testing)."""
registry = get_tool_registry()
tool = registry.get(tool_name)
if not tool:
raise HTTPException(status_code=404, detail=t("api.tool_not_found", name=tool_name))
try:
result = await tool.execute(**body.params)
return {
"success": result.success,
"content": result.content,
"sources": result.sources,
"metadata": result.metadata,
}
except Exception as exc:
logger.exception("Tool execution failed: %s", tool_name)
raise HTTPException(status_code=500, detail=str(exc))
def _sse(event: str, payload: dict[str, Any]) -> str:
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False, default=str)}\n\n"
def _queue_process_emit(
queue: asyncio.Queue[dict[str, Any]],
loop: asyncio.AbstractEventLoop,
):
def emit(event: ProcessLogEvent) -> None:
loop.call_soon_threadsafe(
queue.put_nowait,
{"kind": "process_log", "payload": event.to_dict()},
)
return emit
class _QueueTextStream:
"""Capture stdout/stderr lines as process-log events."""
def __init__(
self,
queue: asyncio.Queue[dict[str, Any]],
loop: asyncio.AbstractEventLoop,
stream,
*,
logger_name: str,
):
self._queue = queue
self._loop = loop
self._stream = stream
self._logger_name = logger_name
self._buffer = ""
def write(self, text: str) -> int:
if self._stream is not None:
self._stream.write(text)
self._stream.flush()
self._buffer += text
while "\n" in self._buffer:
line, self._buffer = self._buffer.split("\n", 1)
self._emit_line(line.rstrip("\r"))
return len(text)
def flush(self):
if self._stream is not None:
self._stream.flush()
if self._buffer.strip():
self._emit_line(self._buffer)
self._buffer = ""
def isatty(self) -> bool:
return False
def _emit_line(self, line: str) -> None:
clean = ANSI_ESCAPE_RE.sub("", line).strip()
if not clean:
return
event = ProcessLogEvent(
level="INFO",
message=clean,
logger=self._logger_name,
timestamp=time.time(),
context=current_log_context(),
)
self._loop.call_soon_threadsafe(
self._queue.put_nowait,
{"kind": "process_log", "payload": event.to_dict()},
)
async def _execute_stream(tool_name: str, params: dict[str, Any]) -> AsyncGenerator[str, None]:
"""Run a tool while streaming structured process logs and the final result."""
registry = get_tool_registry()
tool = registry.get(tool_name)
if not tool:
yield _sse("error", {"detail": t("api.tool_not_found", name=tool_name)})
return
event_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
loop = asyncio.get_running_loop()
stdout_stream = _QueueTextStream(
event_queue, loop, stream=None, logger_name="deeptutor.playground.stdout"
)
stderr_stream = _QueueTextStream(
event_queue, loop, stream=None, logger_name="deeptutor.playground.stderr"
)
result_holder: dict[str, Any] = {}
error_holder: dict[str, str] = {}
done = asyncio.Event()
task_id = f"playground_tool_{tool_name}_{int(time.time() * 1000)}"
async def _run():
try:
import sys
stdout_stream._stream = sys.stdout
stderr_stream._stream = sys.stderr
with bind_log_context(task_id=task_id, capability="playground", sink="ui"):
with capture_process_logs(_queue_process_emit(event_queue, loop), task_id=task_id):
with (
contextlib.redirect_stdout(stdout_stream),
contextlib.redirect_stderr(stderr_stream),
):
result = await tool.execute(**params)
result_holder["data"] = {
"success": result.success,
"content": result.content,
"sources": result.sources,
"metadata": result.metadata,
}
except Exception as exc:
error_holder["detail"] = str(exc)
finally:
stdout_stream.flush()
stderr_stream.flush()
done.set()
task = asyncio.create_task(_run())
t0 = time.monotonic()
try:
while not done.is_set():
try:
item = await asyncio.wait_for(event_queue.get(), timeout=0.15)
yield _sse(item["kind"], item["payload"])
except asyncio.TimeoutError:
pass
while not event_queue.empty():
item = event_queue.get_nowait()
yield _sse(item["kind"], item["payload"])
elapsed_ms = round((time.monotonic() - t0) * 1000)
if error_holder:
yield _sse("error", {"detail": error_holder["detail"], "elapsed_ms": elapsed_ms})
else:
payload = {**result_holder.get("data", {}), "elapsed_ms": elapsed_ms}
yield _sse("result", payload)
finally:
if not task.done():
task.cancel()
@router.post("/tools/{tool_name}/execute-stream")
async def execute_tool_stream(tool_name: str, body: ToolExecuteRequest):
"""Execute a tool and stream process logs + result as SSE."""
return StreamingResponse(
_execute_stream(tool_name, body.params),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
async def _execute_capability_stream(
capability_name: str,
body: CapabilityExecuteRequest,
) -> AsyncGenerator[str, None]:
"""Run a capability while streaming process logs, trace events, and the result."""
partner_id = (body.partner_id or "").strip()
if capability_name == "chat" and partner_id:
from deeptutor.api.routers.partners import (
ChatMessageRequest,
_ensure_running_partner,
_partner_chat_stream,
)
if not body.content.strip():
yield _sse("error", {"detail": "content is required"})
return
try:
await _ensure_running_partner(partner_id)
except HTTPException as exc:
yield _sse("error", {"detail": exc.detail, "status_code": exc.status_code})
return
request = ChatMessageRequest(
content=body.content,
session_id=body.session_id,
chat_id=body.chat_id,
llm_selection=body.llm_selection,
)
async for chunk in _partner_chat_stream(partner_id, request):
yield chunk
return
from deeptutor.core.context import Attachment, UnifiedContext
from deeptutor.runtime.orchestrator import ChatOrchestrator
orch = ChatOrchestrator()
if capability_name not in orch.list_capabilities():
yield _sse("error", {"detail": f"Capability {capability_name!r} not found"})
return
attachments = [
Attachment(
type=a.get("type", "file"),
url=a.get("url", ""),
base64=a.get("base64", ""),
filename=a.get("filename", ""),
mime_type=a.get("mime_type", ""),
)
for a in body.attachments
]
ctx = UnifiedContext(
user_message=body.content,
enabled_tools=body.tools,
active_capability=capability_name,
knowledge_bases=body.knowledge_bases,
attachments=attachments,
config_overrides=body.config,
language=body.language,
)
event_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
loop = asyncio.get_running_loop()
stdout_stream = _QueueTextStream(
event_queue, loop, stream=None, logger_name="deeptutor.playground.stdout"
)
stderr_stream = _QueueTextStream(
event_queue, loop, stream=None, logger_name="deeptutor.playground.stderr"
)
final_result: dict[str, Any] | None = None
error_holder: dict[str, str] = {}
done = asyncio.Event()
task_id = f"playground_capability_{capability_name}_{int(time.time() * 1000)}"
async def _run():
nonlocal final_result
try:
import sys
stdout_stream._stream = sys.stdout
stderr_stream._stream = sys.stderr
with bind_log_context(
task_id=task_id,
capability=capability_name,
sink="ui",
):
with capture_process_logs(_queue_process_emit(event_queue, loop), task_id=task_id):
with (
contextlib.redirect_stdout(stdout_stream),
contextlib.redirect_stderr(stderr_stream),
):
async for event in orch.handle(ctx):
if event.type.value == "result":
final_result = dict(event.metadata)
continue
await event_queue.put({"kind": "stream", "payload": event.to_dict()})
except Exception as exc:
error_holder["detail"] = str(exc)
finally:
stdout_stream.flush()
stderr_stream.flush()
done.set()
task = asyncio.create_task(_run())
t0 = time.monotonic()
try:
while not done.is_set():
try:
item = await asyncio.wait_for(event_queue.get(), timeout=0.15)
yield _sse(item["kind"], item["payload"])
except asyncio.TimeoutError:
pass
while not event_queue.empty():
item = event_queue.get_nowait()
yield _sse(item["kind"], item["payload"])
elapsed_ms = round((time.monotonic() - t0) * 1000)
if error_holder:
yield _sse("error", {"detail": error_holder["detail"], "elapsed_ms": elapsed_ms})
else:
yield _sse(
"result",
{"success": True, "data": final_result or {}, "elapsed_ms": elapsed_ms},
)
finally:
if not task.done():
task.cancel()
@router.post("/capabilities/{capability_name}/execute-stream")
async def execute_capability_stream(
capability_name: str,
body: CapabilityExecuteRequest,
):
"""Execute a capability and stream logs + trace + final result as SSE."""
return StreamingResponse(
_execute_capability_stream(capability_name, body),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
+570
View File
@@ -0,0 +1,570 @@
import asyncio
import base64
from datetime import datetime
import logging
from pathlib import Path
import re
import sys
import traceback
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from deeptutor.agents.question import AgentCoordinator
from deeptutor.api.utils.task_id_manager import TaskIDManager
from deeptutor.logging import (
ProcessLogEvent,
bind_log_context,
capture_process_logs,
current_log_context,
)
from deeptutor.services.config import PROJECT_ROOT, load_config_with_main
from deeptutor.services.llm.config import get_llm_config
from deeptutor.services.path_service import get_path_service
from deeptutor.services.settings.interface_settings import get_ui_language
from deeptutor.tools.question import mimic_exam_questions
from deeptutor.utils.document_validator import DocumentValidator
from deeptutor.utils.error_utils import format_exception_message
# Setup module logger with unified logging system (from config)
config = load_config_with_main("main.yaml", PROJECT_ROOT)
log_dir = config.get("paths", {}).get("user_log_dir") or config.get("logging", {}).get("log_dir")
logger = logging.getLogger(__name__)
router = APIRouter()
def _mimic_output_dir():
# Resolved per-call so a per-user PathService (set after auth) routes
# generated mimic papers under the caller's own workspace instead of
# admin's directory frozen at import time.
return get_path_service().get_question_dir() / "mimic_papers"
@router.websocket("/mimic")
async def websocket_mimic_generate(websocket: WebSocket):
"""
WebSocket endpoint for mimic exam paper question generation.
Supports two modes:
1. Upload PDF directly via WebSocket (base64 encoded)
2. Use a pre-parsed paper directory path
Message format for PDF upload:
{
"mode": "upload",
"pdf_data": "base64_encoded_pdf_content",
"pdf_name": "exam.pdf",
"kb_name": "knowledge_base_name",
"max_questions": 5 // optional
}
Message format for pre-parsed:
{
"mode": "parsed",
"paper_path": "directory_name",
"kb_name": "knowledge_base_name",
"max_questions": 5 // optional
}
"""
from deeptutor.api.routers.auth import ws_auth_failed, ws_require_auth
from deeptutor.multi_user.context import reset_current_user
user_token = await ws_require_auth(websocket)
if user_token is ws_auth_failed:
return
await websocket.accept()
pusher_task = None
original_stdout = sys.stdout
try:
# 1. Wait for config
data = await websocket.receive_json()
mode = data.get("mode", "parsed") # "upload" or "parsed"
kb_name = data.get("kb_name", "ai_textbook")
max_questions = data.get("max_questions")
logger.info(f"Starting mimic generation (mode: {mode}, kb: {kb_name})")
# 2. Setup Log Queue
log_queue = asyncio.Queue()
loop = asyncio.get_running_loop()
task_id = f"question_mimic_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}"
def emit_process_log(event: ProcessLogEvent) -> None:
loop.call_soon_threadsafe(log_queue.put_nowait, event.to_dict())
async def log_pusher():
while True:
entry = await log_queue.get()
try:
await websocket.send_json(entry)
except Exception:
break
log_queue.task_done()
pusher_task = asyncio.create_task(log_pusher())
# 3. Stdout interceptor for capturing prints
# ANSI escape sequence pattern for stripping color codes
ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
class StdoutInterceptor:
def __init__(self, queue, original):
self.queue = queue
self.original_stdout = original
self._closed = False
def write(self, message):
if self._closed:
return
# Write to terminal first (with ANSI codes for color)
try:
self.original_stdout.write(message)
except Exception:
pass
# Strip ANSI escape codes before sending to frontend
clean_message = ANSI_ESCAPE_PATTERN.sub("", message).strip()
# Then send to frontend (non-blocking)
if clean_message:
try:
event = ProcessLogEvent(
level="INFO",
message=clean_message,
logger="deeptutor.question.stdout",
timestamp=datetime.now().timestamp(),
context=current_log_context(),
)
self.queue.put_nowait(event.to_dict())
except (asyncio.QueueFull, RuntimeError):
pass
def flush(self):
if not self._closed:
try:
self.original_stdout.flush()
except Exception:
pass
def close(self):
"""Mark interceptor as closed to prevent further writes."""
self._closed = True
interceptor = StdoutInterceptor(log_queue, original_stdout)
sys.stdout = interceptor
try:
await websocket.send_json(
{"type": "status", "stage": "init", "content": "Initializing..."}
)
pdf_path = None
paper_dir = None
# Handle PDF upload mode
if mode == "upload":
pdf_data = data.get("pdf_data")
pdf_name = data.get("pdf_name", "exam.pdf")
if not pdf_data:
await websocket.send_json(
{"type": "error", "content": "PDF data is required for upload mode"}
)
return
# Decode PDF data first to check size
try:
pdf_bytes = base64.b64decode(pdf_data)
except Exception as e:
await websocket.send_json(
{"type": "error", "content": f"Invalid base64 PDF data: {e}"}
)
return
# Pre-validate filename and file size before writing
try:
safe_name = DocumentValidator.validate_upload_safety(
pdf_name, len(pdf_bytes), {".pdf"}
)
except ValueError as e:
await websocket.send_json({"type": "error", "content": str(e)})
return
# Create batch directory for this mimic session
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
pdf_stem = Path(safe_name).stem
batch_dir = _mimic_output_dir() / f"mimic_{timestamp}_{pdf_stem}"
batch_dir.mkdir(parents=True, exist_ok=True)
# Save uploaded PDF in batch directory
pdf_path = batch_dir / safe_name
await websocket.send_json(
{"type": "status", "stage": "upload", "content": f"Saving PDF: {safe_name}"}
)
# Write the validated PDF bytes
with open(pdf_path, "wb") as f:
f.write(pdf_bytes)
# Additional validation (file readability, etc.)
try:
DocumentValidator.validate_file(pdf_path)
except (ValueError, FileNotFoundError, PermissionError) as e:
# Clean up invalid or inaccessible file
pdf_path.unlink(missing_ok=True)
await websocket.send_json({"type": "error", "content": str(e)})
return
await websocket.send_json(
{
"type": "status",
"stage": "parsing",
"content": "Parsing PDF exam paper (MinerU)...",
}
)
logger.info(f"Saved and validated uploaded PDF to: {pdf_path}")
# Pass batch_dir as output directory
pdf_path = str(pdf_path)
output_dir = str(batch_dir)
elif mode == "parsed":
paper_path = data.get("paper_path")
if not paper_path:
await websocket.send_json(
{"type": "error", "content": "paper_path is required for parsed mode"}
)
return
paper_dir = paper_path
# Create batch directory for parsed mode too
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
batch_dir = _mimic_output_dir() / f"mimic_{timestamp}_{Path(paper_path).name}"
batch_dir.mkdir(parents=True, exist_ok=True)
output_dir = str(batch_dir)
else:
await websocket.send_json({"type": "error", "content": f"Unknown mode: {mode}"})
return
# Create WebSocket callback for real-time progress updates
async def ws_callback(event_type: str, data: dict):
"""Send progress updates to the frontend via WebSocket."""
try:
message = {"type": event_type, **data}
await websocket.send_json(message)
except Exception as e:
logger.debug(f"WebSocket send failed: {e}")
# Run the complete mimic workflow with callback
await websocket.send_json(
{
"type": "status",
"stage": "processing",
"content": "Executing question generation workflow...",
}
)
with bind_log_context(task_id=task_id, capability="deep_question", sink="ui"):
with capture_process_logs(emit_process_log, task_id=task_id):
result = await mimic_exam_questions(
pdf_path=pdf_path,
paper_dir=paper_dir,
kb_name=kb_name,
output_dir=output_dir,
max_questions=max_questions,
ws_callback=ws_callback,
)
if result.get("success"):
# Results are already sent via ws_callback during generation
# Just send the final complete signal
total_ref = result.get("total_reference_questions", 0)
generated = result.get("generated_questions", [])
failed = result.get("failed_questions", [])
logger.info(
f"Mimic generation complete: {len(generated)} succeeded, {len(failed)} failed"
)
try:
await websocket.send_json({"type": "complete"})
except (RuntimeError, WebSocketDisconnect):
logger.debug("WebSocket closed before complete signal could be sent")
else:
error_msg = result.get("error", "Unknown error")
try:
await websocket.send_json({"type": "error", "content": error_msg})
except (RuntimeError, WebSocketDisconnect):
pass
logger.error(f"Mimic generation failed: {error_msg}")
finally:
# Close interceptor and restore stdout
if "interceptor" in locals():
interceptor.close()
sys.stdout = original_stdout
except WebSocketDisconnect:
logger.debug("Client disconnected during mimic generation")
except Exception as e:
logger.exception("Mimic generation error")
error_msg = format_exception_message(e)
try:
await websocket.send_json({"type": "error", "content": error_msg})
except Exception:
pass
finally:
# Ensure stdout is always restored
sys.stdout = original_stdout
# Clean up pusher task
if pusher_task:
try:
pusher_task.cancel()
await pusher_task
except asyncio.CancelledError:
pass # Expected when cancelling
except Exception:
pass
# Drain any remaining items in the queue
try:
while not log_queue.empty():
log_queue.get_nowait()
except Exception:
pass
# Close WebSocket
try:
await websocket.close()
except Exception:
pass
if user_token is not None:
try:
reset_current_user(user_token)
except Exception:
pass
@router.websocket("/generate")
async def websocket_question_generate(websocket: WebSocket):
from deeptutor.api.routers.auth import ws_auth_failed, ws_require_auth
from deeptutor.multi_user.context import reset_current_user
user_token = await ws_require_auth(websocket)
if user_token is ws_auth_failed:
return
await websocket.accept()
# Get task ID manager
task_manager = TaskIDManager.get_instance()
try:
# 1. Wait for config
data = await websocket.receive_json()
requirement = data.get("requirement")
kb_name = data.get("kb_name", "ai_textbook")
count = data.get("count", 1)
if not requirement:
try:
await websocket.send_json({"type": "error", "content": "Requirement is required"})
except (RuntimeError, WebSocketDisconnect):
pass
return
# Generate task ID
task_key = f"question_{kb_name}_{hash(str(requirement))}"
task_id = task_manager.generate_task_id("question_gen", task_key)
# Send task ID to frontend
try:
await websocket.send_json({"type": "task_id", "task_id": task_id})
except (RuntimeError, WebSocketDisconnect):
logger.debug("WebSocket closed, cannot send task_id")
return
logger.info(
f"[{task_id}] Starting question generation: {requirement.get('knowledge_point', 'Unknown')}"
)
# 2. Initialize Coordinator
path_service = get_path_service()
output_base = path_service.get_question_batch_dir(task_id)
try:
llm_config = get_llm_config()
api_key = llm_config.api_key
base_url = llm_config.base_url
api_version = getattr(llm_config, "api_version", None)
except Exception:
api_key = None
base_url = None
api_version = None
coordinator = AgentCoordinator(
api_key=api_key,
base_url=base_url,
api_version=api_version,
kb_name=kb_name,
language=get_ui_language(default=config.get("system", {}).get("language", "en")),
output_dir=str(output_base),
)
# 3. Setup Log Queue for WebSocket streaming
log_queue = asyncio.Queue()
loop = asyncio.get_running_loop()
def emit_process_log(event: ProcessLogEvent) -> None:
loop.call_soon_threadsafe(log_queue.put_nowait, event.to_dict())
# WebSocket callback for coordinator to send structured updates
async def ws_callback(data: dict):
try:
await log_queue.put(data)
except Exception:
pass
coordinator.set_ws_callback(ws_callback)
# 4. Define background pusher for logs
async def log_pusher():
while True:
entry = await log_queue.get()
try:
await websocket.send_json(entry)
except Exception:
break
log_queue.task_done()
pusher_task = asyncio.create_task(log_pusher())
# 5. Run generation while streaming logs bound to this task.
try:
with bind_log_context(task_id=task_id, capability="deep_question", sink="ui"):
with capture_process_logs(emit_process_log, task_id=task_id):
try:
await websocket.send_json({"type": "status", "content": "started"})
except (RuntimeError, WebSocketDisconnect):
logger.debug("WebSocket closed, stopping question generation")
return
# Extract fields from requirement dict
user_topic = (
requirement.get("knowledge_point", "")
if isinstance(requirement, dict)
else str(requirement)
)
preference = (
requirement.get("preference", "") if isinstance(requirement, dict) else ""
)
difficulty = (
requirement.get("difficulty", "") if isinstance(requirement, dict) else ""
)
question_type = (
requirement.get("question_type", "")
if isinstance(requirement, dict)
else ""
)
logger.info(
f"Starting question generation for {count} question(s), topic: {user_topic}"
)
batch_result = await coordinator.generate_from_topic(
user_topic=user_topic,
preference=preference,
num_questions=count,
difficulty=difficulty,
question_type=question_type,
)
# Send batch summary
try:
await websocket.send_json(
{
"type": "batch_summary",
"requested": count,
"completed": batch_result.get("completed", 0),
"failed": batch_result.get("failed", 0),
}
)
except (RuntimeError, WebSocketDisconnect):
pass
if not batch_result.get("success"):
logger.warning(
f"Question generation had failures: {batch_result.get('failed', 0)} failed"
)
# Wait for any pending messages in the queue to be sent
# Give the pusher a moment to process remaining messages
await asyncio.sleep(0.1)
while not log_queue.empty():
await asyncio.sleep(0.05)
# Send complete signal
try:
await websocket.send_json({"type": "complete"})
logger.info(f"[{task_id}] Question generation completed")
task_manager.update_task_status(task_id, "completed")
except (RuntimeError, WebSocketDisconnect):
logger.debug("WebSocket closed, cannot send complete signal")
except Exception as e:
error_msg = format_exception_message(e)
error_traceback = traceback.format_exc()
logger.error(f"Question generation error: {error_msg}")
logger.error(f"Error traceback:\n{error_traceback}")
# Log additional context if available
try:
context_result = locals().get("batch_result")
if context_result is not None:
logger.error(
f"Result type: {type(context_result)}, result keys: {context_result.keys() if isinstance(context_result, dict) else 'N/A'}"
)
if isinstance(context_result, dict) and "validation" in context_result:
validation = context_result["validation"]
logger.error(f"Validation type: {type(validation)}")
if isinstance(validation, dict):
logger.error(f"Validation keys: {validation.keys()}")
logger.error(
f"Issues type: {type(validation.get('issues'))}, value: {validation.get('issues')}"
)
logger.error(
f"Suggestions type: {type(validation.get('suggestions'))}, value: {validation.get('suggestions')}"
)
except Exception as context_error:
logger.warning(f"Failed to log error context: {context_error}")
try:
await websocket.send_json({"type": "error", "content": error_msg})
except (RuntimeError, WebSocketDisconnect):
logger.debug("WebSocket closed, cannot send error message")
task_manager.update_task_status(task_id, "error", error=error_msg)
finally:
pusher_task.cancel()
try:
await pusher_task
except asyncio.CancelledError:
pass
await websocket.close()
except WebSocketDisconnect:
logger.debug("Client disconnected")
except Exception as e:
error_msg = format_exception_message(e)
logger.error(f"WebSocket error: {error_msg}")
finally:
if user_token is not None:
try:
reset_current_user(user_token)
except Exception:
pass
+335
View File
@@ -0,0 +1,335 @@
"""
Question Notebook API — persists quiz questions, bookmarks, and categories.
"""
from __future__ import annotations
import base64 as _b64
import logging
import uuid as _uuid
from fastapi import APIRouter, HTTPException, Query, Response
from pydantic import BaseModel, Field
from deeptutor.services.session import get_sqlite_session_store
from deeptutor.services.storage import get_attachment_store
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Models ────────────────────────────────────────────────────────
class AnswerImageItem(BaseModel):
"""Persisted reference to one image attached to a learner's answer.
The bytes live in the AttachmentStore at ``url``; we never round-trip
base64 back to the client so notebook lookups stay cheap.
"""
id: str = ""
url: str = ""
filename: str = ""
mime_type: str = ""
class NotebookEntryItem(BaseModel):
id: int
session_id: str
session_title: str = ""
turn_id: str = ""
question_id: str = ""
question: str
question_type: str = ""
options: dict[str, str] = {}
correct_answer: str = ""
explanation: str = ""
difficulty: str = ""
user_answer: str = ""
user_answer_images: list[AnswerImageItem] = []
is_correct: bool = False
bookmarked: bool = False
followup_session_id: str = ""
ai_judgment: str = ""
created_at: float
updated_at: float
categories: list[CategoryItem] | None = None
class NotebookEntryListResponse(BaseModel):
items: list[NotebookEntryItem]
total: int
class EntryUpdateRequest(BaseModel):
bookmarked: bool | None = None
followup_session_id: str | None = None
ai_judgment: str | None = None
class CategoryItem(BaseModel):
id: int
name: str
created_at: float = 0
entry_count: int = 0
class CategoryCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
class CategoryRenameRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
class CategoryAddRequest(BaseModel):
category_id: int
class AnswerImageUpload(BaseModel):
"""One image attached to the learner's answer.
Either ``base64`` (new upload) or ``url`` (re-submit of an already
persisted image) must be set. ``id`` is preserved when the client
sends one so the same logical image keeps a stable AttachmentStore
record across resubmissions.
"""
id: str = ""
base64: str = ""
url: str = ""
filename: str = "answer.png"
mime_type: str = "image/png"
class UpsertEntryRequest(BaseModel):
session_id: str
turn_id: str = ""
question_id: str
question: str
question_type: str = ""
options: dict[str, str] | None = None
correct_answer: str = ""
explanation: str = ""
difficulty: str = ""
user_answer: str = ""
# Optional: list of images attached as part of the learner's answer.
# ``None`` means "don't touch any previously-stored images on update";
# an empty list explicitly clears them.
user_answer_images: list[AnswerImageUpload] | None = None
is_correct: bool = False
# ── Entry endpoints ──────────────────────────────────────────────
async def _persist_answer_images(
session_id: str, images: list[AnswerImageUpload] | None
) -> list[dict[str, str]] | None:
"""Materialise base64 image uploads into the AttachmentStore.
Returns a list of ``{id, url, filename, mime_type}`` records suitable
for ``notebook_entries.user_answer_images_json``. ``None`` is returned
when ``images`` is ``None`` (no change to existing stored images).
Records whose bytes fail to upload are dropped from the result with
a warning — losing an image is better than failing the whole upsert.
"""
if images is None:
return None
attachment_store = get_attachment_store()
records: list[dict[str, str]] = []
for image in images:
record_id = (image.id or _uuid.uuid4().hex[:12]).strip()
filename = (image.filename or "answer.png").strip() or "answer.png"
mime_type = (image.mime_type or "image/png").strip() or "image/png"
url = (image.url or "").strip()
if not url and image.base64:
try:
raw_bytes = _b64.b64decode(image.base64, validate=False)
except Exception as exc:
logger.warning("answer image %s rejected: invalid base64 (%s)", filename, exc)
continue
try:
url = await attachment_store.put(
session_id=session_id,
attachment_id=record_id,
filename=filename,
data=raw_bytes,
mime_type=mime_type,
)
except Exception as exc:
logger.warning("attachment store rejected answer image %s: %s", filename, exc)
continue
if not url:
# No url and no base64 — nothing usable.
continue
records.append(
{
"id": record_id,
"url": url,
"filename": filename,
"mime_type": mime_type,
}
)
return records
@router.post("/entries/upsert")
async def upsert_single_entry(payload: UpsertEntryRequest):
store = get_sqlite_session_store()
images_records = await _persist_answer_images(payload.session_id, payload.user_answer_images)
item = payload.model_dump()
# The store expects ``user_answer_images`` as a plain list of dicts
# (or absent to mean "leave the stored images alone"). Strip the
# upload payload version and replace with the persisted records.
item.pop("user_answer_images", None)
if images_records is not None:
item["user_answer_images"] = images_records
try:
await store.upsert_notebook_entries(payload.session_id, [item])
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
entry = await store.find_notebook_entry(
payload.session_id, payload.question_id, turn_id=payload.turn_id
)
if entry is None:
raise HTTPException(status_code=500, detail="Upsert failed")
return entry
@router.get("/entries", response_model=NotebookEntryListResponse)
async def list_entries(
category_id: int | None = Query(default=None),
bookmarked: bool | None = Query(default=None),
is_correct: bool | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> NotebookEntryListResponse:
store = get_sqlite_session_store()
result = await store.list_notebook_entries(
category_id=category_id,
bookmarked=bookmarked,
is_correct=is_correct,
limit=limit,
offset=offset,
)
return NotebookEntryListResponse(
items=[NotebookEntryItem(**item) for item in result["items"]],
total=result["total"],
)
@router.get("/entries/lookup/by-question")
async def lookup_entry(
session_id: str = Query(...),
question_id: str = Query(...),
turn_id: str | None = Query(default=None),
missing_ok: bool = Query(
default=False,
description="Return 204 No Content instead of 404 when the entry is "
"absent — used by the quiz viewer to probe not-yet-saved questions "
"without logging noisy 404s.",
),
):
store = get_sqlite_session_store()
entry = await store.find_notebook_entry(session_id, question_id, turn_id=turn_id)
if entry is None:
if missing_ok:
return Response(status_code=204)
raise HTTPException(status_code=404, detail="Entry not found")
return entry
@router.get("/entries/{entry_id}", response_model=NotebookEntryItem)
async def get_entry(entry_id: int) -> NotebookEntryItem:
store = get_sqlite_session_store()
entry = await store.get_notebook_entry(entry_id)
if entry is None:
raise HTTPException(status_code=404, detail="Entry not found")
return NotebookEntryItem(**entry)
@router.patch("/entries/{entry_id}")
async def update_entry(entry_id: int, payload: EntryUpdateRequest):
store = get_sqlite_session_store()
updates = payload.model_dump(exclude_none=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
updated = await store.update_notebook_entry(entry_id, updates)
if not updated:
raise HTTPException(status_code=404, detail="Entry not found")
return {"updated": True, "id": entry_id}
@router.delete("/entries/{entry_id}")
async def delete_entry(entry_id: int):
store = get_sqlite_session_store()
deleted = await store.delete_notebook_entry(entry_id)
if not deleted:
raise HTTPException(status_code=404, detail="Entry not found")
return {"deleted": True, "id": entry_id}
# ── Entry ↔ Category linking ────────────────────────────────────
@router.post("/entries/{entry_id}/categories")
async def add_entry_to_category(entry_id: int, payload: CategoryAddRequest):
store = get_sqlite_session_store()
entry = await store.get_notebook_entry(entry_id)
if entry is None:
raise HTTPException(status_code=404, detail="Entry not found")
ok = await store.add_entry_to_category(entry_id, payload.category_id)
if not ok:
raise HTTPException(status_code=400, detail="Failed to add to category")
return {"added": True, "entry_id": entry_id, "category_id": payload.category_id}
@router.delete("/entries/{entry_id}/categories/{category_id}")
async def remove_entry_from_category(entry_id: int, category_id: int):
store = get_sqlite_session_store()
removed = await store.remove_entry_from_category(entry_id, category_id)
if not removed:
raise HTTPException(status_code=404, detail="Link not found")
return {"removed": True, "entry_id": entry_id, "category_id": category_id}
# ── Category CRUD ────────────────────────────────────────────────
@router.get("/categories", response_model=list[CategoryItem])
async def list_categories():
store = get_sqlite_session_store()
return await store.list_categories()
@router.post("/categories", response_model=CategoryItem, status_code=201)
async def create_category(payload: CategoryCreateRequest):
store = get_sqlite_session_store()
try:
return await store.create_category(payload.name)
except Exception:
raise HTTPException(status_code=409, detail="Category name already exists")
@router.patch("/categories/{category_id}")
async def rename_category(category_id: int, payload: CategoryRenameRequest):
store = get_sqlite_session_store()
updated = await store.rename_category(category_id, payload.name)
if not updated:
raise HTTPException(status_code=404, detail="Category not found")
return {"updated": True, "id": category_id, "name": payload.name}
@router.delete("/categories/{category_id}")
async def delete_category(category_id: int):
store = get_sqlite_session_store()
deleted = await store.delete_category(category_id)
if not deleted:
raise HTTPException(status_code=404, detail="Category not found")
return {"deleted": True, "id": category_id}
+427
View File
@@ -0,0 +1,427 @@
"""AI judge WebSocket — grades a learner's quiz answer.
Mounted on its own (without router-level HTTP auth dependencies) because
WebSocket upgrades cannot use FastAPI's HTTP dependency injection, so we
rely on ``ws_require_auth`` inside the handler — mirroring the pattern
used by ``unified_ws``.
"""
from __future__ import annotations
import base64 as _b64
import logging
from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from deeptutor.services.config import PROJECT_ROOT, load_config_with_main
from deeptutor.services.llm import stream as llm_stream
from deeptutor.services.settings.interface_settings import get_ui_language
from deeptutor.utils.error_utils import format_exception_message
logger = logging.getLogger(__name__)
_config = load_config_with_main("main.yaml", PROJECT_ROOT)
router = APIRouter()
_JUDGE_SYSTEM_PROMPTS = {
"zh": (
"你是一名严谨且鼓励学习者的助教,正在批改一道测验题。"
"请基于题目、参考答案与解析,对学习者的作答给出针对性的判定与反馈。\n\n"
"回答要求:\n"
"- 先用一行明确结论:✅ 正确 / ⚠️ 部分正确 / ❌ 不正确,并简短点明关键判定依据。\n"
"- 然后分条列出:哪里做对了、哪里出错或缺漏、应该如何改正。\n"
"- 若题目本身有多种合理答案,请承认学习者的合理之处。\n"
"- 直接以学习者的作答为对象,不要泛泛而谈。\n"
"- 全程使用中文。"
),
"en": (
"You are a rigorous yet encouraging teaching assistant grading a learner's quiz answer. "
"Use the question, reference answer, and explanation to deliver a targeted assessment.\n\n"
"Requirements:\n"
"- Open with one line that states the verdict: ✅ Correct / ⚠️ Partially correct / ❌ Incorrect, "
"and the key reason.\n"
"- Then list: what the learner got right, what is wrong or missing, and how to fix it.\n"
"- If multiple reasonable answers exist, acknowledge what the learner did well.\n"
"- Speak directly to the learner's submission — do not give a generic lecture.\n"
"- Reply in English."
),
}
def _build_judge_user_prompt(
*,
language: str,
question: str,
question_type: str,
options: dict | None,
correct_answer: str,
explanation: str,
user_answer: str,
has_image: bool,
image_count: int = 0,
) -> str:
options_block = ""
if options:
try:
options_block = "\n".join(f" {k}. {v}" for k, v in options.items())
except Exception:
options_block = ""
if language == "zh":
parts = [
f"题目类型:{question_type or 'unknown'}",
f"题干:\n{question}",
]
if options_block:
parts.append(f"选项:\n{options_block}")
if correct_answer:
parts.append(f"参考答案:\n{correct_answer}")
if explanation:
parts.append(f"参考解析:\n{explanation}")
parts.append(
"学习者作答:\n"
+ (
user_answer.strip()
if user_answer and user_answer.strip()
else "(仅提交了图片,无文字作答)"
)
)
if has_image:
count_text = (
f"学习者另附了 {image_count} 张图片作为作答内容"
if image_count > 1
else "学习者另附了一张图片作为作答内容"
)
parts.append(f"{count_text},请结合图片中的文字/公式/草图一并判定。")
parts.append("请针对该学习者的具体作答给出 AI 评判。")
else:
parts = [
f"Question type: {question_type or 'unknown'}",
f"Question:\n{question}",
]
if options_block:
parts.append(f"Options:\n{options_block}")
if correct_answer:
parts.append(f"Reference answer:\n{correct_answer}")
if explanation:
parts.append(f"Reference explanation:\n{explanation}")
parts.append(
"Learner's answer:\n"
+ (
user_answer.strip()
if user_answer and user_answer.strip()
else "(only an image was submitted, no typed answer)"
)
)
if has_image:
if image_count > 1:
parts.append(
f"The learner attached {image_count} images as part of the answer. "
"Read their text/formulas/sketches and factor them into the judgment."
)
else:
parts.append(
"The learner attached an image as part of the answer. "
"Read its text/formulas/sketches and factor it into the judgment."
)
parts.append("Produce an AI judgment that addresses this learner's specific answer.")
return "\n\n".join(parts)
async def _build_multimodal_user_content(
*,
text: str,
image_records: list[dict[str, str]],
) -> list[dict[str, Any]]:
"""Compose an OpenAI-style content-parts array with text + image blocks.
For ``url``-only records we resolve local AttachmentStore paths to
base64 here (most providers can fetch external URLs themselves, but
locally-hosted ``/api/attachments/...`` is only reachable from the
browser). Falls back to passing the URL through when resolution is
not possible.
"""
from urllib.parse import unquote, urlparse
from deeptutor.services.storage import get_attachment_store
content: list[dict[str, Any]] = [{"type": "text", "text": text}]
attachment_store = get_attachment_store()
resolve = getattr(attachment_store, "resolve_path", None)
for record in image_records:
b64 = record.get("base64") or ""
url = record.get("url") or ""
filename = record.get("filename") or "answer.png"
mime_type = record.get("mime_type") or _guess_image_mime(filename)
if not b64 and url and resolve is not None:
try:
parsed = urlparse(url)
parts = (parsed.path or url).strip("/").split("/")
# Expected shape: api/attachments/{sid}/{aid}/{name}
if len(parts) >= 5 and parts[0] == "api" and parts[1] == "attachments":
sid = unquote(parts[2])
aid = unquote(parts[3])
name = unquote("/".join(parts[4:]))
target = resolve(session_id=sid, attachment_id=aid, filename=name)
if target is not None and target.exists():
b64 = _b64.b64encode(target.read_bytes()).decode("ascii")
except Exception as exc:
logger.debug("Could not resolve %s to bytes: %s", url, exc)
if b64:
data_url = f"data:{mime_type};base64,{b64}"
content.append({"type": "image_url", "image_url": {"url": data_url}})
elif url:
content.append({"type": "image_url", "image_url": {"url": url}})
return content
def _guess_image_mime(filename: str | None) -> str:
if not filename:
return "image/png"
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
return {
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
"webp": "image/webp",
}.get(ext, "image/png")
@router.websocket("/question/judge")
async def websocket_quiz_judge(websocket: WebSocket):
"""Stream an AI judgment for a single quiz answer.
Auth is enforced via ``ws_require_auth`` rather than a router-level
HTTP dependency — see module docstring.
Client → Server (initial JSON):
{
"question": str,
"question_type": str,
"options": dict | null,
"correct_answer": str,
"explanation": str,
"user_answer": str,
# New: list of image entries. Each entry has either ``base64``
# (no ``data:`` prefix) or ``url`` (already hosted via the
# AttachmentStore). ``user_answer_image`` (single, base64) is
# still accepted for backward compatibility.
"user_answer_images": [
{"base64": str, "url": str, "filename": str, "mime_type": str},
...
] | null,
"user_answer_image": str | null, # legacy single-image form
"image_filename": str | null, # legacy filename for the above
"language": "zh" | "en",
}
Server → Client (streaming):
{"type": "started"}
{"type": "text", "content": "..."} # zero or more
{"type": "done"}
{"type": "error", "content": "..."}
"""
from deeptutor.api.routers.auth import ws_auth_failed, ws_require_auth
from deeptutor.multi_user.context import reset_current_user
user_token = await ws_require_auth(websocket)
if user_token is ws_auth_failed:
return
await websocket.accept()
async def safe_send(payload: dict[str, Any]) -> bool:
try:
await websocket.send_json(payload)
return True
except (WebSocketDisconnect, RuntimeError, ConnectionError):
return False
try:
data = await websocket.receive_json()
except WebSocketDisconnect:
return
except Exception as exc:
await safe_send({"type": "error", "content": f"Invalid request: {exc}"})
try:
await websocket.close()
except Exception:
pass
if user_token is not None:
try:
reset_current_user(user_token)
except Exception:
pass
return
question_text = (data.get("question") or "").strip()
if not question_text:
await safe_send({"type": "error", "content": "Question is required"})
try:
await websocket.close()
except Exception:
pass
if user_token is not None:
try:
reset_current_user(user_token)
except Exception:
pass
return
requested_language = (data.get("language") or "").strip().lower()
if requested_language not in ("zh", "en"):
requested_language = get_ui_language(
default=_config.get("system", {}).get("language", "en")
)
if requested_language not in ("zh", "en"):
requested_language = "en"
user_answer = data.get("user_answer") or ""
# Resolve the image set. New clients send ``user_answer_images`` (list);
# legacy clients send the single ``user_answer_image`` + ``image_filename``
# pair. Build a uniform list of ``{base64, url, filename, mime_type}`` so
# the downstream multimodal-message builder doesn't care which form
# arrived.
raw_images = data.get("user_answer_images")
image_records: list[dict[str, str]] = []
if isinstance(raw_images, list):
for entry in raw_images:
if not isinstance(entry, dict):
continue
b64 = entry.get("base64") or ""
url = entry.get("url") or ""
if isinstance(b64, str) and b64.startswith("data:"):
try:
b64 = b64.split(",", 1)[1]
except IndexError:
b64 = ""
if not b64 and not url:
continue
filename = entry.get("filename") or "answer.png"
mime_type = entry.get("mime_type") or _guess_image_mime(filename)
image_records.append(
{
"base64": b64,
"url": url,
"filename": filename,
"mime_type": mime_type,
}
)
else:
legacy_b64 = data.get("user_answer_image") or ""
if isinstance(legacy_b64, str) and legacy_b64.startswith("data:"):
try:
legacy_b64 = legacy_b64.split(",", 1)[1]
except IndexError:
legacy_b64 = ""
if legacy_b64:
legacy_filename = data.get("image_filename") or "answer.png"
image_records.append(
{
"base64": legacy_b64,
"url": "",
"filename": legacy_filename,
"mime_type": _guess_image_mime(legacy_filename),
}
)
has_image = bool(image_records)
options_value = data.get("options") if isinstance(data.get("options"), dict) else None
system_prompt = _JUDGE_SYSTEM_PROMPTS.get(requested_language, _JUDGE_SYSTEM_PROMPTS["en"])
user_prompt = _build_judge_user_prompt(
language=requested_language,
question=question_text,
question_type=data.get("question_type") or "",
options=options_value,
correct_answer=data.get("correct_answer") or "",
explanation=data.get("explanation") or "",
user_answer=user_answer,
has_image=has_image,
image_count=len(image_records),
)
if not (user_answer.strip() or has_image):
await safe_send(
{
"type": "error",
"content": ("No answer to judge — submit a typed answer or attach an image."),
}
)
try:
await websocket.close()
except Exception:
pass
if user_token is not None:
try:
reset_current_user(user_token)
except Exception:
pass
return
await safe_send({"type": "started"})
# Build a multimodal user message when ≥1 image was attached. We pass
# the full ``messages`` array to ``factory.stream`` so it forwards the
# content-parts unchanged (the single-image ``image_data`` kwarg only
# supports one image).
stream_kwargs: dict[str, Any] = {}
if has_image:
from deeptutor.services.llm import config as _llm_config_mod
from deeptutor.services.llm.capabilities import supports_vision
llm_cfg = _llm_config_mod.get_llm_config()
binding = getattr(llm_cfg, "binding", "openai") or "openai"
model = getattr(llm_cfg, "model", "") or ""
if supports_vision(binding, model):
user_content = await _build_multimodal_user_content(
text=user_prompt,
image_records=image_records,
)
stream_kwargs["messages"] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
]
else:
# Vision-incapable model — fall back to text-only judge so the
# learner still gets feedback on their typed answer.
logger.info(
"Judge: %s/%s does not support vision; dropping %d image(s)",
binding,
model,
len(image_records),
)
try:
async for chunk in llm_stream(
prompt=user_prompt,
system_prompt=system_prompt,
**stream_kwargs,
):
if not chunk:
continue
if not await safe_send({"type": "text", "content": chunk}):
break
await safe_send({"type": "done"})
except WebSocketDisconnect:
logger.debug("AI judge client disconnected mid-stream")
except Exception as exc:
logger.exception("AI judge stream failed")
await safe_send({"type": "error", "content": format_exception_message(exc)})
finally:
try:
await websocket.close()
except Exception:
pass
if user_token is not None:
try:
reset_current_user(user_token)
except Exception:
pass
+230
View File
@@ -0,0 +1,230 @@
"""
Unified session history API.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field, field_validator
from deeptutor.services.session import get_session_store, get_sqlite_session_store
from deeptutor.services.storage.attachment_store import get_attachment_store
logger = logging.getLogger(__name__)
router = APIRouter()
class SessionRenameRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=100)
class BranchSelectionRequest(BaseModel):
"""Edit-branch picker state: `{parent_message_id: chosen_child_id}`.
Stored inside the session preferences blob so it survives reloads
without a dedicated column.
"""
selected_branches: dict[str, int] = Field(default_factory=dict)
class QuizResultItem(BaseModel):
question_id: str = ""
question: str = Field(..., min_length=1)
question_type: str = ""
options: dict[str, str] | None = None
user_answer: str = ""
correct_answer: str = ""
explanation: str | None = ""
difficulty: str | None = ""
is_correct: bool
@field_validator("options", mode="before")
@classmethod
def _coerce_options(cls, v):
return v if isinstance(v, dict) else {}
@field_validator("explanation", "difficulty", mode="before")
@classmethod
def _coerce_str(cls, v):
return v if isinstance(v, str) else ""
class QuizResultsRequest(BaseModel):
answers: list[QuizResultItem] = Field(default_factory=list)
turn_id: str = ""
def _format_quiz_results_message(answers: list[QuizResultItem]) -> str:
total = len(answers)
correct = sum(1 for item in answers if item.is_correct)
score_pct = round((correct / total) * 100) if total else 0
lines = ["[Quiz Performance]"]
for idx, item in enumerate(answers, 1):
question = item.question.strip().replace("\n", " ")
user_answer = (item.user_answer or "").strip() or "(blank)"
status = "Correct" if item.is_correct else "Incorrect"
suffix = f" ({status})"
if not item.is_correct and (item.correct_answer or "").strip():
suffix = f" ({status}, correct: {(item.correct_answer or '').strip()})"
qid = f"[{item.question_id}] " if item.question_id else ""
lines.append(f"{idx}. {qid}Q: {question} -> Answered: {user_answer}{suffix}")
lines.append(f"Score: {correct}/{total} ({score_pct}%)")
return "\n".join(lines)
@router.get("")
async def list_sessions(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
):
store = get_session_store()
sessions = await store.list_sessions(limit=limit, offset=offset)
return {"sessions": sessions}
# Cap (in characters) for a single event payload returned to the UI. RAG
# tools can attach whole KB documents to ``tool_result``/``observation``
# events; the frontend TraceSurface only needs a preview, and the LLM context
# is built from a separate content-only store, so capping here never affects
# model input.
MAX_EVENT_PAYLOAD = 1024 * 1024
_TRUNCATION_NOTICE = "\n\n[... content truncated]"
_TRUNCATABLE_EVENT_TYPES = ("tool_result", "observation")
def _truncate_oversized_events(
messages: list[dict[str, Any]], limit: int = MAX_EVENT_PAYLOAD
) -> None:
"""Cap oversized ``tool_result``/``observation`` payloads in place.
The session store already returns each message's events as a parsed
``events`` list (see ``SqliteSessionStore._serialize_message``), so we
mutate that list directly. Only the UI rendering path is affected.
"""
def _cap(container: dict[str, Any], field: str) -> bool:
value = container.get(field)
if isinstance(value, str) and len(value) > limit:
container[field] = value[:limit] + _TRUNCATION_NOTICE
return True
return False
for msg in messages:
events = msg.get("events")
if not isinstance(events, list):
continue
for event in events:
if not isinstance(event, dict) or event.get("type") not in _TRUNCATABLE_EVENT_TYPES:
continue
truncated = _cap(event, "content")
tool_metadata = (event.get("metadata") or {}).get("tool_metadata")
if isinstance(tool_metadata, dict):
for field in ("content", "answer"):
truncated = _cap(tool_metadata, field) or truncated
if truncated:
event["_truncated"] = True
@router.get("/{session_id}")
async def get_session(session_id: str):
store = get_session_store()
session = await store.get_session_with_messages(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
_truncate_oversized_events(session.get("messages", []))
return session
@router.patch("/{session_id}")
async def rename_session(session_id: str, payload: SessionRenameRequest):
store = get_session_store()
updated = await store.update_session_title(session_id, payload.title)
if not updated:
raise HTTPException(status_code=404, detail="Session not found")
session = await store.get_session(session_id)
return {"session": session}
@router.delete("/{session_id}")
async def delete_session(session_id: str):
store = get_session_store()
deleted = await store.delete_session(session_id)
if not deleted:
raise HTTPException(status_code=404, detail="Session not found")
try:
await get_attachment_store().delete_session(session_id)
except Exception:
logger.exception("failed to clean up attachments for session %s", session_id)
return {"deleted": True, "session_id": session_id}
@router.put("/{session_id}/branch-selection")
async def update_branch_selection(session_id: str, payload: BranchSelectionRequest):
store = get_sqlite_session_store()
session = await store.get_session(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
updated = await store.update_session_preferences(
session_id, {"selected_branches": dict(payload.selected_branches)}
)
if not updated:
raise HTTPException(status_code=404, detail="Session not found")
return {"selected_branches": payload.selected_branches}
@router.delete("/{session_id}/messages/{message_id}")
async def delete_turn_by_message(session_id: str, message_id: int):
store = get_sqlite_session_store()
result = await store.delete_turn_by_message(session_id, message_id)
if result["was_running"]:
raise HTTPException(
status_code=409, detail="Cannot delete a message while its turn is running"
)
if not result["deleted"]:
raise HTTPException(status_code=404, detail="Message not found")
attachment_store = get_attachment_store()
for aid in result["attachment_ids"]:
try:
await attachment_store.delete_attachment(session_id, aid)
except Exception:
logger.exception("failed to delete attachment %s for session %s", aid, session_id)
return result
@router.post("/{session_id}/quiz-results")
async def record_quiz_results(session_id: str, payload: QuizResultsRequest):
if not payload.answers:
raise HTTPException(status_code=400, detail="Quiz results are required")
store = get_sqlite_session_store()
session = await store.get_session(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
content = _format_quiz_results_message(payload.answers)
await store.add_message(
session_id=session_id,
role="user",
content=content,
capability="deep_question",
)
notebook_count = 0
try:
notebook_count = await store.upsert_notebook_entries(
session_id,
[{**item.model_dump(), "turn_id": payload.turn_id} for item in payload.answers],
)
except Exception:
logger.warning(
"Failed to upsert notebook entries for session %s", session_id, exc_info=True
)
return {
"recorded": True,
"session_id": session_id,
"answer_count": len(payload.answers),
"notebook_count": notebook_count,
"content": content,
}
File diff suppressed because it is too large Load Diff
+305
View File
@@ -0,0 +1,305 @@
"""
Skills API Router
=================
CRUD endpoints for user-authored SKILL.md files stored under
``data/user/workspace/skills/<name>/SKILL.md``.
Mounted at ``/api/v1/skills``.
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from deeptutor.multi_user.context import get_current_user
from deeptutor.multi_user.skill_access import (
assigned_skill_detail,
assigned_skill_ids,
assigned_skill_infos,
)
from deeptutor.services.skill import get_skill_service
from deeptutor.services.skill.service import (
InvalidSkillNameError,
InvalidTagError,
SkillExistsError,
SkillImportError,
SkillNotFoundError,
SkillReadOnlyError,
TagExistsError,
TagNotFoundError,
)
router = APIRouter()
class CreateSkillRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
description: str = ""
content: str = ""
tags: list[str] = Field(default_factory=list)
class UpdateSkillRequest(BaseModel):
description: str | None = None
content: str | None = None
rename_to: str | None = None
tags: list[str] | None = None
class InstallSkillRequest(BaseModel):
"""Install a hub skill into the caller's own skill layer.
``ref`` is a ``<hub>:<slug>[@version]`` reference (the bare hub prefix
defaults to ``eduhub``). The web "import from EduHub" flow always builds an
``eduhub:`` ref so a spoofed bridge message can't redirect the install to an
arbitrary registry.
"""
ref: str = Field(..., min_length=1, max_length=256)
name: str | None = None
force: bool = False
allow_unverified: bool = False
class CreateTagRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=32)
class RenameTagRequest(BaseModel):
rename_to: str = Field(..., min_length=1, max_length=32)
# ── tag routes (declared before `/{name}` so they are not shadowed) ──
@router.get("/tags/list")
async def list_tags() -> dict[str, list[str]]:
service = get_skill_service()
return {"tags": service.list_tags()}
@router.post("/tags/create")
async def create_tag(payload: CreateTagRequest) -> dict[str, str]:
service = get_skill_service()
try:
tag = service.create_tag(payload.name)
except TagExistsError as exc:
raise HTTPException(status_code=409, detail=f"Tag already exists: {exc}")
except InvalidTagError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"name": tag}
@router.put("/tags/{tag}")
async def rename_tag(tag: str, payload: RenameTagRequest) -> dict[str, str]:
service = get_skill_service()
try:
new_tag = service.rename_tag(tag, payload.rename_to)
except TagNotFoundError:
raise HTTPException(status_code=404, detail=f"Tag not found: {tag}")
except TagExistsError as exc:
raise HTTPException(status_code=409, detail=f"Tag already exists: {exc}")
except InvalidTagError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"name": new_tag}
@router.delete("/tags/{tag}")
async def delete_tag(tag: str) -> dict[str, str]:
service = get_skill_service()
try:
service.delete_tag(tag)
except TagNotFoundError:
raise HTTPException(status_code=404, detail=f"Tag not found: {tag}")
except InvalidTagError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"status": "deleted", "name": tag}
# ── skill routes ───────────────────────────────────────────────────
@router.get("/list")
async def list_skills() -> dict[str, list[dict[str, object]]]:
service = get_skill_service()
own_items = [info.to_dict() for info in service.list_skills()]
user = get_current_user()
if user.is_admin:
return {"skills": own_items}
own_names = {item.get("name") for item in own_items}
merged = list(own_items)
for assigned in assigned_skill_infos(user.id):
if assigned.get("name") in own_names:
continue
merged.append(assigned)
return {"skills": merged}
# ── hub browse (in-app EduHub skill browser; declared before `/{name}`) ──
@router.get("/hub/catalog")
async def hub_catalog(hub: str = "eduhub", q: str = "", limit: int = 50) -> dict[str, object]:
"""Proxy a skill hub's public catalog for the in-app browser.
The web "Import from EduHub" panel renders these rows in DeepTutor's own
UI — no embedded iframe, no login — so users can browse, search, and
one-click download skills. Returns ``web_url`` (the hub's site origin) so
the panel can offer a "view on EduHub" link out.
"""
import asyncio
from deeptutor.services.skill.hub import ClawHubProvider, HubError, get_hub_provider
try:
provider = get_hub_provider(hub)
except HubError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if not isinstance(provider, ClawHubProvider):
raise HTTPException(status_code=400, detail=f"Hub `{hub}` does not support browsing.")
try:
skills = await asyncio.to_thread(provider.catalog, query=q, limit=limit)
except HubError as exc:
raise HTTPException(status_code=502, detail=str(exc))
return {"hub": provider.name, "web_url": provider.web_origin, "skills": skills}
@router.get("/hub/detail")
async def hub_detail(slug: str, hub: str = "eduhub") -> dict[str, object]:
"""Full metadata + SKILL.md body for one hub skill (browser detail view)."""
import asyncio
from deeptutor.services.skill.hub import ClawHubProvider, HubError, get_hub_provider
try:
provider = get_hub_provider(hub)
except HubError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if not isinstance(provider, ClawHubProvider):
raise HTTPException(status_code=400, detail=f"Hub `{hub}` does not support browsing.")
try:
detail = await asyncio.to_thread(provider.detail, slug)
except HubError as exc:
raise HTTPException(status_code=502, detail=str(exc))
detail["web_url"] = f"{provider.web_origin}/skills/{slug}"
return detail
@router.get("/{name}")
async def get_skill(name: str) -> dict[str, object]:
service = get_skill_service()
try:
return service.get_detail(name).to_dict()
except SkillNotFoundError:
# User scope doesn't have it. Fall through to admin-assigned lookup
# below, which returns 403 if the user has no grant for it.
pass
except InvalidSkillNameError as exc:
raise HTTPException(status_code=400, detail=str(exc))
user = get_current_user()
if user.is_admin:
# Admin scope already checked above; nothing else to look at.
raise HTTPException(status_code=404, detail=f"Skill not found: {name}")
if name not in assigned_skill_ids(user.id):
raise HTTPException(status_code=403, detail="Skill is not assigned to you")
detail = assigned_skill_detail(name)
if detail is None:
raise HTTPException(status_code=404, detail=f"Skill not found: {name}")
return detail
@router.post("/create")
async def create_skill(payload: CreateSkillRequest) -> dict[str, object]:
service = get_skill_service()
try:
info = service.create(
name=payload.name,
description=payload.description,
content=payload.content,
tags=list(payload.tags or []),
)
return info.to_dict()
except SkillExistsError:
raise HTTPException(status_code=409, detail=f"Skill already exists: {payload.name}")
except InvalidSkillNameError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except InvalidTagError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.post("/install")
async def install_skill(payload: InstallSkillRequest) -> dict[str, object]:
"""Import a hub skill (e.g. from EduHub) into the caller's own skill layer.
Lands the package in the same per-user dir that ``/create`` writes to, so
the imported skill shows up in this user's Skills list. The install gate
(``suspicious`` verdict abort, safe extraction, ``always`` stripping)
lives in :func:`deeptutor.services.skill.hub.install_from_hub`.
"""
import asyncio
from deeptutor.services.skill.hub import HubError, install_from_hub
service = get_skill_service()
try:
outcome = await asyncio.to_thread(
install_from_hub,
payload.ref,
service=service,
rename_to=payload.name,
force=payload.force,
allow_unverified=payload.allow_unverified,
)
except SkillExistsError as exc:
raise HTTPException(status_code=409, detail=f"Skill already exists: {exc}")
except (SkillImportError, InvalidSkillNameError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
except HubError as exc:
raise HTTPException(status_code=502, detail=str(exc))
return {
"skill": outcome.result.info.to_dict(),
"verdict": {"status": outcome.verdict.status, "detail": outcome.verdict.detail},
"version": outcome.ref.version,
}
@router.put("/{name}")
async def update_skill(name: str, payload: UpdateSkillRequest) -> dict[str, object]:
service = get_skill_service()
try:
info = service.update(
name,
description=payload.description,
content=payload.content,
rename_to=payload.rename_to,
tags=payload.tags,
)
return info.to_dict()
except SkillNotFoundError:
raise HTTPException(status_code=404, detail=f"Skill not found: {name}")
except SkillReadOnlyError as exc:
raise HTTPException(status_code=403, detail=str(exc))
except SkillExistsError as exc:
raise HTTPException(status_code=409, detail=str(exc))
except InvalidSkillNameError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except InvalidTagError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.delete("/{name}")
async def delete_skill(name: str) -> dict[str, str]:
service = get_skill_service()
try:
service.delete(name)
return {"status": "deleted", "name": name}
except SkillNotFoundError:
raise HTTPException(status_code=404, detail=f"Skill not found: {name}")
except SkillReadOnlyError as exc:
raise HTTPException(status_code=403, detail=str(exc))
except InvalidSkillNameError as exc:
raise HTTPException(status_code=400, detail=str(exc))
+324
View File
@@ -0,0 +1,324 @@
"""Subagent connections API.
Backs the "My Agents → connected agents" feature: detect which local agent CLIs
(Claude Code / Codex) are installed on this machine, connect one as a pointer KB
the chat composer can select, and configure the consult budget. Connections are
stored as ``type: subagent`` knowledge bases (per-user, via the KB manager), so
they ride the same selection/persistence path as the other connected KB types —
the subagent capability drives them live, nothing is indexed.
The CLIs run on the host with the host user's own credentials, so detection is
machine-global; whether a connection is usable is simply "is the CLI installed
here". If it isn't, the UI just doesn't offer it.
"""
from __future__ import annotations
import asyncio
import json
import logging
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from deeptutor.api.routers.auth import require_admin
from deeptutor.knowledge.kb_types import SUBAGENT_KB_TYPE
from deeptutor.multi_user.knowledge_access import current_kb_manager
from deeptutor.multi_user.partner_access import assert_partner_allowed, visible_partner_cards
from deeptutor.services.rag.linked_kb import assert_path_allowed
from deeptutor.services.subagent import (
PARTNER_BACKEND_KIND,
detect_all,
list_backend_kinds,
load_subagent_settings,
save_subagent_settings,
settings_from_dict,
)
logger = logging.getLogger(__name__)
router = APIRouter()
class ConnectSubagentRequest(BaseModel):
name: str
agent_kind: str
cwd: str = ""
# For the partner backend (``agent_kind == "partner"``): which partner to
# consult. Ignored by the local-CLI backends, which use ``cwd`` instead.
partner_id: str = ""
class SubagentSettingsPayload(BaseModel):
consult_budget: int | None = None
backends: dict[str, dict] | None = Field(default=None)
class SubagentMessageRequest(BaseModel):
chat_session_id: str = ""
message: str
@router.get("/detect")
async def detect_subagents():
"""Report which agent CLIs are installed and usable on this machine."""
detections = await detect_all()
return {"backends": [d.to_dict() for d in detections]}
@router.get("/backends/options")
async def backend_options():
"""Synced model + reasoning-effort options per backend (settings page sync)."""
from deeptutor.services.subagent.models import list_backend_options
options = await list_backend_options()
return {"backends": [o.to_dict() for o in options]}
@router.post("/backends/{kind}/sync")
async def sync_backend(kind: str):
"""Re-pull one backend's model catalog (the settings "sync" button).
For Claude Code this scrapes its ``/model`` TUI live and caches the result;
for Codex it re-reads the CLI-maintained cache.
"""
from deeptutor.services.subagent import get_backend
from deeptutor.services.subagent.models import sync_backend_options
backend = get_backend(kind)
if backend is None or not getattr(backend, "local_cli", True):
# Only local CLIs have a model catalog to sync; partners run their own.
raise HTTPException(status_code=400, detail=f"Unknown agent kind: {kind!r}")
options = await sync_backend_options(kind)
return options.to_dict()
@router.get("/partners")
async def list_visible_partners():
"""Partners the current user can connect & consult.
Returns every partner for an admin, or just the ones an admin has assigned
for a non-admin. The partner CRUD API (``/api/v1/partners``) stays fully
admin-gated; this is the read surface the connect flow and the partner list
page use, so a non-admin sees their assigned partners without a 403.
"""
return {"partners": visible_partner_cards()}
@router.get("/connections")
async def list_connections():
"""List the current user's connected subagents."""
manager = current_kb_manager()
connections = []
for name in manager.list_knowledge_bases():
meta = manager.get_metadata(name)
if not isinstance(meta, dict) or meta.get("type") != SUBAGENT_KB_TYPE:
continue
connections.append(
{
"name": name,
"agent_kind": meta.get("agent_kind", ""),
"cwd": meta.get("cwd", ""),
"partner_id": meta.get("partner_id", ""),
"description": meta.get("description", ""),
"created_at": meta.get("created_at"),
"updated_at": meta.get("updated_at"),
}
)
return {"connections": connections}
@router.post("/connections")
async def create_connection(payload: ConnectSubagentRequest):
"""Connect a subagent (a local CLI, or one of the user's partners) as a selectable KB.
A partner connection (``agent_kind == "partner"``) binds a ``partner_id``
instead of a working directory: consulting it opens a fresh session on that
partner, exactly as if the user started one from the partner page. Every
consult within one DeepTutor chat lands in that one partner session.
"""
name = (payload.name or "").strip()
agent_kind = (payload.agent_kind or "").strip()
if not name or not agent_kind:
raise HTTPException(status_code=400, detail="Both name and agent_kind are required.")
if agent_kind not in list_backend_kinds():
raise HTTPException(status_code=400, detail=f"Unknown agent kind: {agent_kind!r}")
resolved_cwd = ""
partner_id = ""
if agent_kind == PARTNER_BACKEND_KIND:
partner_id = (payload.partner_id or "").strip()
if not partner_id:
raise HTTPException(
status_code=400, detail="A partner_id is required to connect a partner."
)
# Partners are admin-managed, but an admin can assign one to a user via
# the grant system. An admin may connect any partner; a non-admin only a
# partner assigned to them (403 otherwise). The partner still runs in its
# own isolated scope — connecting just lets the user consult it in chat.
assert_partner_allowed(partner_id)
from deeptutor.services.partners import get_partner_manager
if not get_partner_manager().partner_exists(partner_id):
raise HTTPException(status_code=400, detail=f"No partner named {partner_id!r}.")
else:
raw_cwd = (payload.cwd or "").strip()
if raw_cwd:
try:
resolved_cwd = str(assert_path_allowed(raw_cwd))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
try:
manager = current_kb_manager()
entry = manager.register_subagent_connection(
name, agent_kind, cwd=resolved_cwd, partner_id=partner_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # pragma: no cover - defensive
logger.error("Error connecting subagent: %s", exc)
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
"status": "connected",
"name": name,
"agent_kind": entry["agent_kind"],
"cwd": entry["cwd"],
"partner_id": entry.get("partner_id", ""),
}
@router.delete("/connections/{name}")
async def delete_connection(name: str):
"""Disconnect a subagent (removes the pointer KB; touches no files)."""
manager = current_kb_manager()
meta = manager.get_metadata(name)
if not isinstance(meta, dict) or meta.get("type") != SUBAGENT_KB_TYPE:
raise HTTPException(status_code=404, detail=f"No connected subagent named {name!r}.")
try:
manager.delete_knowledge_base(name, confirm=True)
except Exception as exc: # pragma: no cover - defensive
logger.error("Error disconnecting subagent: %s", exc)
raise HTTPException(status_code=500, detail=str(exc)) from exc
# Drop any remembered live-session ids for this connection.
from deeptutor.services.subagent.sessions import forget_connection
forget_connection(name)
return {"status": "disconnected", "name": name}
def _ndjson(obj: dict) -> str:
return json.dumps(obj, ensure_ascii=False) + "\n"
@router.post("/connections/{name}/message")
async def message_connection(name: str, payload: SubagentMessageRequest):
"""Send a message straight to a connected subagent and stream its run.
This is the sidebar's "talk to the agent directly" path: it resumes the same
live session DeepTutor consults (shared via the cross-turn registry, keyed by
chat session + connection), so the agent keeps full context. Streams the
native run as newline-delimited JSON, in the same channel shape the chat WS
uses, so the sidebar transcript renders it identically.
"""
message = (payload.message or "").strip()
if not message:
raise HTTPException(status_code=400, detail="A non-empty 'message' is required.")
manager = current_kb_manager()
meta = manager.get_metadata(name)
if not isinstance(meta, dict) or meta.get("type") != SUBAGENT_KB_TYPE:
raise HTTPException(status_code=404, detail=f"No connected subagent named {name!r}.")
from deeptutor.services.subagent import get_backend
from deeptutor.services.subagent.sessions import get_session, remember_session, session_key
kind = str(meta.get("agent_kind") or "")
cwd = str(meta.get("cwd") or "")
partner_id = str(meta.get("partner_id") or "")
backend = get_backend(kind)
if backend is None:
raise HTTPException(status_code=400, detail=f"Unknown agent kind: {kind!r}")
config = load_subagent_settings().backend(kind)
skey = session_key(payload.chat_session_id, name) if payload.chat_session_id else ""
resume_id = get_session(skey) if skey else None
async def event_stream():
queue: asyncio.Queue = asyncio.Queue()
async def on_event(event) -> None:
await queue.put(("event", event))
async def run() -> None:
try:
res = await backend.consult(
message,
on_event=on_event,
cwd=cwd or None,
session_id=resume_id,
config=config,
partner_id=partner_id or None,
)
await queue.put(("done", res))
except Exception as exc: # pragma: no cover - defensive
logger.warning("subagent message failed: %s", exc, exc_info=True)
await queue.put(("fail", str(exc)))
task = asyncio.create_task(run())
# The user's own message heads the exchange.
yield _ndjson({"channel": "user_question", "text": message})
try:
while True:
kind_, item = await queue.get()
if kind_ == "event":
line = {"channel": item.kind, "text": item.text}
merge_id = (item.meta or {}).get("merge_id")
if merge_id:
# Namespace away from the chat turn's consult merge ids.
line["merge_id"] = f"side:{merge_id}"
yield _ndjson(line)
elif kind_ == "done":
if skey and item.session_id:
remember_session(skey, item.session_id, kind=kind, cwd=cwd)
yield _ndjson(
{"done": True, "success": item.success, "session_id": item.session_id or ""}
)
break
else: # fail
yield _ndjson({"channel": "error", "text": item})
yield _ndjson({"done": True, "success": False})
break
finally:
if not task.done():
await task
return StreamingResponse(event_stream(), media_type="application/x-ndjson")
@router.get("/settings")
async def get_settings():
"""Read the consult budget and per-backend run config."""
return load_subagent_settings().to_dict()
@router.put("/settings", dependencies=[Depends(require_admin)])
async def update_settings(payload: SubagentSettingsPayload):
"""Update the subagent settings (admin-gated; deployment-wide)."""
merged = load_subagent_settings().to_dict()
if payload.consult_budget is not None:
merged["consult_budget"] = payload.consult_budget
if payload.backends is not None:
# Merge per backend (and per field) so saving one backend's settings
# never clobbers the other's or any unsent field.
backends = dict(merged.get("backends") or {})
for kind, cfg in payload.backends.items():
backends[str(kind)] = {**(backends.get(str(kind)) or {}), **(cfg or {})}
merged["backends"] = backends
settings = settings_from_dict(merged)
save_subagent_settings(settings)
return settings.to_dict()
__all__ = ["router"]
+322
View File
@@ -0,0 +1,322 @@
"""
System Status API Router
Manages system status checks and model connection tests
"""
from datetime import datetime
import time
from fastapi import APIRouter
from pydantic import BaseModel
from deeptutor.multi_user.context import get_current_user
from deeptutor.services.config import resolve_search_runtime_config
from deeptutor.services.embedding import get_embedding_client, get_embedding_config
from deeptutor.services.llm import complete as llm_complete
from deeptutor.services.llm import get_llm_config, get_token_limit_kwargs
from deeptutor.services.search import web_search
router = APIRouter()
class TestResponse(BaseModel):
success: bool
message: str
model: str | None = None
response_time_ms: float | None = None
error: str | None = None
@router.get("/runtime-topology")
async def get_runtime_topology():
"""
Describe the current execution topology.
This makes the unified runtime explicit for operators and frontend code:
interactive chat turns should prefer `/api/v1/ws`, while a few routers still
exist as compatibility or isolated subsystem endpoints.
"""
return {
"primary_runtime": {
"transport": "/api/v1/ws",
"manager": "TurnRuntimeManager",
"orchestrator": "ChatOrchestrator",
"session_store": "SQLiteSessionStore",
"capability_entry": "CapabilityRegistry",
"tool_entry": "ToolRegistry",
},
"compatibility_routes": [
{"router": "chat", "mode": "legacy_adapter_target"},
{"router": "solve", "mode": "legacy_adapter_target"},
{"router": "question", "mode": "legacy_specialized"},
{"router": "research", "mode": "legacy_specialized"},
],
"isolated_subsystems": [
{"router": "co_writer", "mode": "independent_subsystem"},
{"router": "plugins_api", "mode": "playground_transport"},
],
}
@router.get("/status")
async def get_system_status():
"""
Get overall system status including backend and model configurations
Returns:
Dictionary containing status of backend, LLM, embeddings, and search
"""
result = {
"backend": {"status": "online", "timestamp": datetime.now().isoformat()},
"llm": {"status": "unknown", "model": None, "testable": True},
"embeddings": {"status": "unknown", "model": None, "testable": True},
"search": {"status": "optional", "provider": None, "testable": True},
}
# Check backend status (this endpoint itself proves backend is online)
result["backend"]["status"] = "online"
# Check LLM configuration
try:
llm_config = get_llm_config()
result["llm"]["model"] = llm_config.model
result["llm"]["status"] = "configured"
except ValueError as e:
result["llm"]["status"] = "not_configured"
result["llm"]["error"] = str(e)
except Exception as e:
result["llm"]["status"] = "error"
result["llm"]["error"] = str(e)
# Check Embeddings configuration
try:
embedding_config = get_embedding_config()
result["embeddings"]["model"] = embedding_config.model
result["embeddings"]["status"] = "configured"
except ValueError as e:
result["embeddings"]["status"] = "not_configured"
result["embeddings"]["error"] = str(e)
except Exception as e:
result["embeddings"]["status"] = "error"
result["embeddings"]["error"] = str(e)
try:
search_config = resolve_search_runtime_config()
if search_config.requested_provider:
result["search"]["provider"] = search_config.provider
if search_config.unsupported_provider:
result["search"]["status"] = "unsupported"
result["search"]["error"] = (
f"{search_config.requested_provider} is deprecated/unsupported. "
"Switch to brave/tavily/jina/searxng/duckduckgo/perplexity."
)
elif search_config.deprecated_provider:
result["search"]["status"] = "deprecated"
result["search"]["error"] = (
f"{search_config.requested_provider} is deprecated. "
"Switch to brave/tavily/jina/searxng/duckduckgo/perplexity."
)
elif search_config.missing_credentials:
result["search"]["status"] = "not_configured"
result["search"]["error"] = (
f"{search_config.requested_provider} requires api_key. "
"Set profile.api_key in Settings > Catalog."
)
elif search_config.provider == "none":
result["search"]["status"] = "disabled"
result["search"]["testable"] = False
else:
result["search"]["status"] = "configured"
if search_config.fallback_reason:
result["search"]["status"] = "fallback"
result["search"]["error"] = search_config.fallback_reason
except Exception as e:
result["search"]["status"] = "error"
result["search"]["error"] = str(e)
# Non-admin users have no need to know which model the admin configured;
# exposing the name leaks operational detail and would let curious users
# fingerprint the deployment. Strip the identifying fields.
if not get_current_user().is_admin:
for section in ("llm", "embeddings"):
result[section].pop("model", None)
result["search"].pop("provider", None)
return result
@router.post("/test/llm", response_model=TestResponse)
async def test_llm_connection():
"""
Test LLM model connection by sending a simple completion request
Returns:
Test result with success status and response time
"""
start_time = time.time()
try:
llm_config = get_llm_config()
model = llm_config.model
base_url = llm_config.base_url.rstrip("/")
# Sanitize Base URL (remove /chat/completions suffix if present)
for suffix in ["/chat/completions", "/completions"]:
if base_url.endswith(suffix):
base_url = base_url[: -len(suffix)]
# Handle API Key (inject dummy if missing for local LLMs)
api_key = llm_config.api_key
if not api_key:
api_key = "sk-no-key-required"
# Send a minimal test request with a prompt that guarantees output
test_prompt = "Say 'OK' to confirm you are working. Do not produce long output."
token_kwargs = get_token_limit_kwargs(model, max_tokens=200)
response = await llm_complete(
model=model,
prompt=test_prompt,
system_prompt="You are a helpful assistant. Respond briefly.",
binding=llm_config.binding,
api_key=api_key,
base_url=base_url,
temperature=0.1,
**token_kwargs,
)
response_time = (time.time() - start_time) * 1000
if response and len(response.strip()) > 0:
return TestResponse(
success=True,
message="LLM connection successful",
model=model,
response_time_ms=round(response_time, 2),
)
return TestResponse(
success=False,
message="LLM connection failed: Empty response",
model=model,
error="Empty response from API",
)
except ValueError as e:
return TestResponse(success=False, message=f"LLM configuration error: {e!s}", error=str(e))
except Exception as e:
response_time = (time.time() - start_time) * 1000
return TestResponse(
success=False,
message=f"LLM connection failed: {e!s}",
response_time_ms=round(response_time, 2),
error=str(e),
)
@router.post("/test/embeddings", response_model=TestResponse)
async def test_embeddings_connection():
"""
Test Embeddings model connection by sending a simple embedding request
Returns:
Test result with success status and response time
"""
start_time = time.time()
try:
embedding_config = get_embedding_config()
embedding_client = get_embedding_client()
model = embedding_config.model
binding = embedding_config.binding
# Probe a tiny batch so "connection OK" also exercises the path RAG
# uses for multi-chunk indexing.
test_texts = ["test", "retrieval batch probe"]
embeddings = await embedding_client.embed(test_texts)
response_time = (time.time() - start_time) * 1000
if (
embeddings is not None
and len(embeddings) == len(test_texts)
and all(len(vector) > 0 for vector in embeddings)
and len({len(vector) for vector in embeddings}) == 1
):
return TestResponse(
success=True,
message=f"Embeddings connection successful ({binding} provider)",
model=model,
response_time_ms=round(response_time, 2),
)
return TestResponse(
success=False,
message="Embeddings connection failed: Invalid response",
model=model,
error="Embedding response must contain one non-empty vector per input",
)
except ValueError as e:
return TestResponse(
success=False, message=f"Embeddings configuration error: {e!s}", error=str(e)
)
except Exception as e:
response_time = (time.time() - start_time) * 1000
return TestResponse(
success=False,
message=f"Embeddings connection failed: {e!s}",
response_time_ms=round(response_time, 2),
error=str(e),
)
@router.post("/test/search", response_model=TestResponse)
async def test_search_connection():
start_time = time.time()
try:
search_config = resolve_search_runtime_config()
if search_config.provider == "none":
return TestResponse(
success=False,
message="Search is disabled",
error="Set a Search provider in Settings > Catalog.",
)
if search_config.unsupported_provider:
return TestResponse(
success=False,
message=(
f"Search provider `{search_config.requested_provider}` is deprecated/unsupported."
),
error="Switch to brave/tavily/jina/searxng/duckduckgo/perplexity",
)
if search_config.missing_credentials:
return TestResponse(
success=False,
message=f"Search provider `{search_config.requested_provider}` missing credentials.",
error="Set profile.api_key in Settings > Catalog.",
)
result = web_search("DeepTutor health check", provider=search_config.provider)
response_time = (time.time() - start_time) * 1000
answer = result.get("answer") or result.get("search_results")
if not answer:
raise ValueError("Search provider returned no content")
return TestResponse(
success=True,
message="Search connection successful",
model=search_config.provider,
response_time_ms=round(response_time, 2),
)
except ValueError as e:
return TestResponse(
success=False, message=f"Search configuration error: {e!s}", error=str(e)
)
except Exception as e:
response_time = (time.time() - start_time) * 1000
return TestResponse(
success=False,
message=f"Search connection check failed: {e!s}",
response_time_ms=round(response_time, 2),
error=str(e),
)
+222
View File
@@ -0,0 +1,222 @@
"""
Tools API Router
================
Read-only listing of the chat agent's built-in tools, used by the Settings UI
to render the "Tools" sub-page. Returns each tool's definition (name,
description, parameters) alongside its bilingual prompt hints, so the frontend
can show authoritative copy without duplicating the catalog.
"""
from __future__ import annotations
import logging
from typing import Any, Literal
from fastapi import APIRouter
from pydantic import BaseModel
from deeptutor.api.routers.settings import get_enabled_optional_tools
from deeptutor.core.tool_protocol import BaseTool, ToolDefinition, ToolPromptHints
from deeptutor.i18n.metadata_i18n import tool_description_i18n
from deeptutor.tools.builtin import (
BUILTIN_TOOL_TYPES,
COMING_SOON_TOOL_TYPES,
TOOL_ALIASES,
USER_TOGGLEABLE_TOOL_NAMES,
)
logger = logging.getLogger(__name__)
router = APIRouter()
class ToolParameterPayload(BaseModel):
name: str
type: str
description: str = ""
required: bool = True
default: Any = None
enum: list[str] | None = None
class ToolAliasPayload(BaseModel):
name: str
description: str = ""
input_format: str = ""
when_to_use: str = ""
phase: str = ""
class ToolHintsPayload(BaseModel):
short_description: str = ""
when_to_use: str = ""
input_format: str = ""
guideline: str = ""
note: str = ""
phase: str = ""
aliases: list[ToolAliasPayload] = []
class BuiltinToolPayload(BaseModel):
name: str
description: str
description_i18n: dict[Literal["en", "zh"], str] = {}
parameters: list[ToolParameterPayload]
hints: dict[Literal["en", "zh"], ToolHintsPayload]
aliases: list[str] = []
# True iff the user is allowed to switch this tool on/off from the
# /settings/tools UI. Locked-on tools (auto-mounted by the chat
# pipeline under context gates) report ``False`` and the UI renders
# them as informational entries only.
toggleable: bool = False
# Whether the tool is currently on. For toggleable tools this
# reflects the user's saved preference; for locked-on tools this is
# always ``True``.
enabled: bool = True
# ``coming_soon`` tools are NOT registered with the runtime — the chat
# agent cannot invoke them. They are listed here only so the settings
# page can render a placeholder card explaining the capability is on
# the roadmap. The frontend should lock the toggle and show a badge.
coming_soon: bool = False
# The capability that owns this tool (e.g. ``solve`` / ``mastery``), or
# ``None`` for a plain system built-in. Owned tools are reused by their
# capability on top of the shared built-in surface; the settings UI groups
# them under their owner, below the built-in section.
capability: str | None = None
class ToolsListResponse(BaseModel):
tools: list[BuiltinToolPayload]
enabled_optional_tools: list[str]
def _serialise_definition(
definition: ToolDefinition,
) -> tuple[str, str, list[ToolParameterPayload]]:
params = [
ToolParameterPayload(
name=p.name,
type=p.type,
description=p.description,
required=p.required,
default=p.default,
enum=p.enum,
)
for p in definition.parameters
]
return definition.name, definition.description, params
def _serialise_hints(hints: ToolPromptHints) -> ToolHintsPayload:
return ToolHintsPayload(
short_description=hints.short_description,
when_to_use=hints.when_to_use,
input_format=hints.input_format,
guideline=hints.guideline,
note=hints.note,
phase=hints.phase,
aliases=[
ToolAliasPayload(
name=alias.name,
description=alias.description,
input_format=alias.input_format,
when_to_use=alias.when_to_use,
phase=alias.phase,
)
for alias in hints.aliases
],
)
def _collect_aliases_for(tool_name: str) -> list[str]:
return sorted(alias for alias, (target, _) in TOOL_ALIASES.items() if target == tool_name)
def _build_tool_payload(
tool: BaseTool,
*,
enabled_optional: set[str],
coming_soon: bool = False,
capability: str | None = None,
) -> BuiltinToolPayload:
name, description, parameters = _serialise_definition(tool.get_definition())
descriptions = tool_description_i18n(name, description)
toggleable = (not coming_soon) and (name in USER_TOGGLEABLE_TOOL_NAMES)
if coming_soon:
enabled = False
elif toggleable:
enabled = name in enabled_optional
else:
enabled = True
return BuiltinToolPayload(
name=name,
description=descriptions.get("en") or description,
description_i18n=descriptions,
parameters=parameters,
hints={
"en": _serialise_hints(tool.get_prompt_hints(language="en")),
"zh": _serialise_hints(tool.get_prompt_hints(language="zh")),
},
aliases=_collect_aliases_for(name),
toggleable=toggleable,
enabled=enabled,
coming_soon=coming_soon,
capability=capability,
)
@router.get("", response_model=ToolsListResponse)
async def list_builtin_tools() -> ToolsListResponse:
"""Return all built-in tools the chat agent can invoke, plus any
coming-soon placeholders for the settings page."""
from deeptutor.capabilities import capability_tool_owners
enabled_optional = set(get_enabled_optional_tools())
owners = capability_tool_owners()
payloads: list[BuiltinToolPayload] = []
for tool_type in BUILTIN_TOOL_TYPES:
try:
instance = tool_type()
payloads.append(
_build_tool_payload(
instance,
enabled_optional=enabled_optional,
capability=owners.get(instance.name),
)
)
except Exception:
logger.exception("Failed to serialise tool %s", tool_type.__name__)
for tool_type in COMING_SOON_TOOL_TYPES:
try:
instance = tool_type()
payloads.append(
_build_tool_payload(
instance,
enabled_optional=enabled_optional,
coming_soon=True,
)
)
except Exception:
logger.exception("Failed to serialise coming-soon tool %s", tool_type.__name__)
# Guard against the unlikely case of name collision (e.g. someone
# accidentally registers the same tool both as built-in and coming-soon).
seen: set[str] = set()
deduped: list[BuiltinToolPayload] = []
for payload in payloads:
if payload.name in seen:
continue
seen.add(payload.name)
deduped.append(payload)
# Toggleable tools outside the user's admin grant don't exist for them:
# hidden here so the settings page and composer match what turn_runtime
# will actually allow.
from deeptutor.multi_user.tool_access import allowed_optional_tools
allowed = allowed_optional_tools()
if allowed is not None:
deduped = [p for p in deduped if not p.toggleable or p.name in allowed]
return ToolsListResponse(
tools=deduped,
enabled_optional_tools=sorted(enabled_optional),
)
+324
View File
@@ -0,0 +1,324 @@
"""
Unified WebSocket Endpoint
==========================
Single ``/api/v1/ws`` endpoint for turn-based execution and replayable streaming.
Supported client message ``type`` values:
- ``message`` / ``start_turn`` — start a new turn from a payload.
- ``subscribe_turn`` — stream events of an existing turn (with ``after_seq``).
- ``subscribe_session`` — stream events of the active turn for a session.
- ``resume_from`` — resume an in-flight turn after reconnection.
- ``unsubscribe`` — stop a previously created subscription.
- ``cancel_turn`` — cancel a running turn.
- ``submit_user_reply`` — deliver the user's reply for an ``ask_user``
paused turn so the agentic loop can resume on the same turn.
- ``regenerate`` — re-run the last user message in the given session as a
brand-new turn. Replaces the trailing assistant message (if any) and
reuses the session's stored capability/tools/preferences. Optional
``overrides`` field accepts ``capability``, ``tools``, ``knowledge_bases``,
``language``, ``config``, ``notebook_references``, ``history_references``.
Errors: ``regenerate_busy`` (another turn is running) and
``nothing_to_regenerate`` (no prior user message).
- ``check_active_turn`` — report whether the session has a live running turn;
replies with ``active_turn_info`` (``turn_id``/``status``), marking stale
persisted "running" rows as cancelled when no live execution exists.
- ``user_input`` — deliver a learner answer to the turn's StreamBus
(resolves a pending ``wait_for_input``, e.g. an ``ask_user`` pause).
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
router = APIRouter()
logger = logging.getLogger(__name__)
@router.websocket("/ws")
async def unified_websocket(ws: WebSocket) -> None:
from deeptutor.api.routers.auth import ws_auth_failed, ws_require_auth
from deeptutor.multi_user.context import reset_current_user
user_token = await ws_require_auth(ws)
if user_token is ws_auth_failed:
return
await ws.accept()
closed = False
subscription_tasks: dict[str, asyncio.Task[None]] = {}
async def safe_send(data: dict[str, Any]) -> None:
nonlocal closed
if closed:
return
try:
# default=str so one non-serializable value inside an event can
# never poison the push channel (send_json would raise, flag the
# socket as closed, and silently freeze the stream for the user).
await ws.send_text(json.dumps(data, ensure_ascii=False, default=str))
except Exception:
closed = True
async def stop_subscription(key: str) -> None:
task = subscription_tasks.pop(key, None)
if task is None:
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def subscribe_turn(turn_id: str, after_seq: int = 0) -> None:
from deeptutor.services.session import get_turn_runtime_manager
async def _forward() -> None:
runtime = get_turn_runtime_manager()
async for event in runtime.subscribe_turn(turn_id, after_seq=after_seq):
await safe_send(event)
await stop_subscription(turn_id)
subscription_tasks[turn_id] = asyncio.create_task(_forward())
async def subscribe_session(session_id: str, after_seq: int = 0) -> None:
from deeptutor.services.session import get_turn_runtime_manager
async def _forward() -> None:
runtime = get_turn_runtime_manager()
async for event in runtime.subscribe_session(session_id, after_seq=after_seq):
await safe_send(event)
key = f"session:{session_id}"
await stop_subscription(key)
subscription_tasks[key] = asyncio.create_task(_forward())
try:
while not closed:
raw = await ws.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
await safe_send({"type": "error", "content": "Invalid JSON."})
continue
msg_type = msg.get("type")
if msg_type in {"message", "start_turn"}:
from deeptutor.services.session import get_turn_runtime_manager
runtime = get_turn_runtime_manager()
try:
_, turn = await runtime.start_turn(msg)
except RuntimeError as exc:
await safe_send(
{
"type": "error",
"source": "unified_ws",
"stage": "",
"content": str(exc),
"metadata": {"turn_terminal": True, "status": "rejected"},
"session_id": str(msg.get("session_id") or ""),
"turn_id": "",
"seq": 0,
}
)
continue
await subscribe_turn(turn["id"], after_seq=0)
continue
if msg_type == "ping":
# Client-side heartbeat. Respond with a lightweight pong so
# the client knows the socket is alive; the client never
# consumes pong as a user-visible event (see unified-ws.ts
# filter below) but does refresh ``lastReceivedAt`` from it.
await safe_send({"type": "pong"})
continue
if msg_type == "subscribe_turn":
turn_id = str(msg.get("turn_id") or "").strip()
if not turn_id:
await safe_send({"type": "error", "content": "Missing turn_id."})
continue
await subscribe_turn(turn_id, after_seq=int(msg.get("after_seq") or 0))
continue
if msg_type == "subscribe_session":
session_id = str(msg.get("session_id") or "").strip()
if not session_id:
await safe_send({"type": "error", "content": "Missing session_id."})
continue
await subscribe_session(session_id, after_seq=int(msg.get("after_seq") or 0))
continue
if msg_type == "check_active_turn":
session_id = str(msg.get("session_id") or "").strip()
if not session_id:
await safe_send({"type": "error", "content": "Missing session_id."})
continue
from deeptutor.services.session import get_turn_runtime_manager
runtime = get_turn_runtime_manager()
active_turn = await runtime.store.get_active_turn(session_id)
if active_turn:
# Verify the turn has a live execution; stale persisted
# "running" rows (e.g. after server restart) have none.
turn_id = active_turn["id"]
has_live = await runtime.has_live_execution(turn_id)
if has_live:
await safe_send(
{
"type": "active_turn_info",
"turn_id": turn_id,
"status": active_turn.get("status", "running"),
}
)
else:
# Stale turn from a previous process — mark it terminal
# so create_turn won't reject the upcoming start_turn.
await runtime.store.update_turn_status(
turn_id, "cancelled", "Stale turn after restart"
)
await safe_send(
{"type": "active_turn_info", "turn_id": "", "status": "none"}
)
else:
await safe_send({"type": "active_turn_info", "turn_id": "", "status": "none"})
continue
if msg_type == "resume_from":
turn_id = str(msg.get("turn_id") or "").strip()
if not turn_id:
await safe_send({"type": "error", "content": "Missing turn_id."})
continue
await subscribe_turn(turn_id, after_seq=int(msg.get("seq") or 0))
continue
if msg_type == "unsubscribe":
turn_id = str(msg.get("turn_id") or "").strip()
if turn_id:
await stop_subscription(turn_id)
session_id = str(msg.get("session_id") or "").strip()
if session_id:
await stop_subscription(f"session:{session_id}")
continue
if msg_type == "cancel_turn":
turn_id = str(msg.get("turn_id") or "").strip()
if not turn_id:
await safe_send({"type": "error", "content": "Missing turn_id."})
continue
from deeptutor.services.session import get_turn_runtime_manager
runtime = get_turn_runtime_manager()
cancelled = await runtime.cancel_turn(turn_id)
if not cancelled:
await safe_send({"type": "error", "content": f"Turn not found: {turn_id}"})
continue
if msg_type == "submit_user_reply":
turn_id = str(msg.get("turn_id") or "").strip()
if not turn_id:
await safe_send({"type": "error", "content": "Missing turn_id."})
continue
# Accept either the legacy ``text`` (single free-form
# reply) or the v2 ``answers`` (list of {questionId, text}
# pairs). Empty text is allowed (lets the user signal "I
# have no answer" without typing).
text = msg.get("text")
text_str = str(text) if text is not None else None
answers_raw = msg.get("answers")
answers: list[dict[str, Any]] | None = None
if isinstance(answers_raw, list):
cleaned: list[dict[str, Any]] = []
for entry in answers_raw:
if not isinstance(entry, dict):
continue
qid = str(entry.get("questionId") or entry.get("id") or "").strip()
if not qid:
continue
cleaned.append({"questionId": qid, "text": str(entry.get("text") or "")})
answers = cleaned or None
from deeptutor.services.session import get_turn_runtime_manager
runtime = get_turn_runtime_manager()
accepted = await runtime.submit_user_reply(turn_id, text=text_str, answers=answers)
if not accepted:
await safe_send(
{
"type": "error",
"content": (f"Turn {turn_id} is not awaiting a user reply."),
}
)
continue
if msg_type == "regenerate":
session_id = str(msg.get("session_id") or "").strip()
if not session_id:
await safe_send({"type": "error", "content": "Missing session_id."})
continue
from deeptutor.services.session import get_turn_runtime_manager
runtime = get_turn_runtime_manager()
overrides = msg.get("overrides") if isinstance(msg.get("overrides"), dict) else None
try:
_, turn = await runtime.regenerate_last_turn(
session_id,
overrides=overrides,
)
except RuntimeError as exc:
await safe_send(
{
"type": "error",
"source": "unified_ws",
"stage": "",
"content": str(exc),
"metadata": {
"turn_terminal": True,
"status": "rejected",
"reason": str(exc),
},
"session_id": session_id,
"turn_id": "",
"seq": 0,
}
)
continue
await subscribe_turn(turn["id"], after_seq=0)
continue
if msg_type == "user_input":
turn_id = str(msg.get("turn_id") or "").strip()
if not turn_id:
await safe_send({"type": "error", "content": "Missing turn_id for user_input."})
continue
from deeptutor.core.stream_bus import get_bus
bus = get_bus(turn_id)
if bus is None:
await safe_send(
{"type": "error", "content": f"No active bus for turn: {turn_id}"}
)
continue
bus.submit_input(str(msg.get("content") or ""))
continue
await safe_send({"type": "error", "content": f"Unknown type: {msg_type}"})
except WebSocketDisconnect:
logger.debug("Client disconnected from /ws")
except Exception as exc:
logger.error("Unified WS error: %s", exc, exc_info=True)
await safe_send({"type": "error", "content": str(exc)})
finally:
closed = True
for key in list(subscription_tasks.keys()):
await stop_subscription(key)
if user_token is not None:
reset_current_user(user_token)
+130
View File
@@ -0,0 +1,130 @@
"""Voice endpoints — text-to-speech and speech-to-text.
These are thin HTTP surfaces over :mod:`deeptutor.services.voice`. Config comes
from the admin-managed model catalog (``services.tts`` / ``services.stt``), so
voice is shared infrastructure like embedding/search — any authenticated user
may call it; it is not gated by per-user LLM grants.
"""
from __future__ import annotations
import io
import logging
import wave
from fastapi import APIRouter, File, Form, HTTPException, Response, UploadFile, status
from pydantic import BaseModel, Field
from deeptutor.services.voice import (
VoiceProviderError,
synthesize_speech,
transcribe_audio,
)
logger = logging.getLogger(__name__)
router = APIRouter()
# Guard against pathological uploads (the providers cap well below this anyway).
_MAX_AUDIO_BYTES = 25 * 1024 * 1024 # 25 MB, matching OpenAI's limit.
_DEFAULT_PCM_SAMPLE_RATE = 24_000
_DEFAULT_PCM_CHANNELS = 1
_PCM16_SAMPLE_WIDTH = 2
class TTSRequest(BaseModel):
"""Text-to-speech request body."""
text: str = Field(..., min_length=1)
voice: str | None = None
format: str | None = None
def _parse_pcm_content_type(content_type: str) -> tuple[int, int] | None:
"""Return ``(sample_rate, channels)`` when a provider sent raw PCM audio."""
media_type, *params = (content_type or "").split(";")
if media_type.strip().lower() not in {"audio/pcm", "audio/x-pcm", "audio/l16"}:
return None
sample_rate = _DEFAULT_PCM_SAMPLE_RATE
channels = _DEFAULT_PCM_CHANNELS
for item in params:
key, sep, value = item.strip().partition("=")
if not sep:
continue
key = key.strip().lower()
value = value.strip().strip('"')
try:
parsed = int(value)
except ValueError:
continue
if key in {"rate", "sample-rate", "samplerate"} and parsed > 0:
sample_rate = parsed
elif key in {"channels", "channel"} and parsed > 0:
channels = parsed
return sample_rate, channels
def _pcm16_to_wav(audio: bytes, *, sample_rate: int, channels: int) -> bytes:
"""Wrap provider PCM16 bytes in a WAV container browsers can play."""
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav:
wav.setnchannels(channels)
wav.setsampwidth(_PCM16_SAMPLE_WIDTH)
wav.setframerate(sample_rate)
wav.writeframes(audio)
return buffer.getvalue()
@router.post("/tts")
async def text_to_speech(payload: TTSRequest) -> Response:
"""Synthesize ``text`` to audio using the active TTS provider."""
try:
audio, content_type = await synthesize_speech(
payload.text,
voice=payload.voice,
response_format=payload.format,
)
except ValueError as exc: # missing/invalid configuration
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except VoiceProviderError as exc:
logger.warning("TTS provider error: %s", exc)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
pcm_info = _parse_pcm_content_type(content_type)
if pcm_info:
sample_rate, channels = pcm_info
audio = _pcm16_to_wav(audio, sample_rate=sample_rate, channels=channels)
content_type = "audio/wav"
return Response(
content=audio,
media_type=content_type,
headers={"Cache-Control": "no-store"},
)
@router.post("/stt")
async def speech_to_text(
file: UploadFile = File(...),
language: str | None = Form(default=None),
) -> dict[str, str]:
"""Transcribe an uploaded audio clip using the active STT provider."""
audio = await file.read()
if not audio:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty audio upload.")
if len(audio) > _MAX_AUDIO_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="Audio exceeds the 25 MB limit.",
)
try:
text = await transcribe_audio(
audio,
filename=file.filename or "audio.webm",
content_type=file.content_type or "application/octet-stream",
language=language,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except VoiceProviderError as exc:
logger.warning("STT provider error: %s", exc)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
return {"text": text}
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python
"""
Uvicorn Server Startup Script
Uses Python API instead of command line to avoid Windows path parsing issues.
"""
import asyncio
import os
from pathlib import Path
import sys
from deeptutor.runtime.home import get_runtime_home
# Windows: uvicorn defaults to SelectorEventLoop which does not support
# asyncio.create_subprocess_exec. Switch to ProactorEventLoop so that
# child-process APIs (used by Math Animator renderer, etc.) work correctly.
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
import uvicorn
# Force unbuffered output
os.environ["PYTHONUNBUFFERED"] = "1"
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(line_buffering=True, encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(line_buffering=True, encoding="utf-8", errors="replace")
def main() -> None:
# Runtime workspace root owns data/user/settings and generated outputs.
project_root = get_runtime_home()
os.chdir(str(project_root))
# Get port from configuration
from deeptutor.logging import configure_logging
from deeptutor.runtime.mode import RunMode, set_mode
from deeptutor.services.setup import get_backend_port
set_mode(RunMode.SERVER)
configure_logging()
backend_port = get_backend_port(project_root)
# Configure reload_excludes to skip directories that shouldn't trigger reloads
# Use absolute paths to ensure they're properly resolved
reload_excludes = [
str(project_root / "venv"), # Virtual environment
str(project_root / ".venv"), # Virtual environment (alternative name)
str(project_root / "data"), # Data directory (includes knowledge_bases, user data, logs)
str(project_root / "node_modules"), # Node modules (if any at root)
str(project_root / "web" / "node_modules"), # Web node modules
str(project_root / "web" / ".next"), # Next.js build
str(project_root / ".git"), # Git directory
str(project_root / "scripts"), # Scripts directory - don't reload on launcher changes
]
# Filter out non-existent directories to avoid warnings
reload_excludes = [d for d in reload_excludes if Path(d).exists()]
# Start uvicorn server with reload enabled
uvicorn.run(
"deeptutor.api.main:app",
host="0.0.0.0",
port=backend_port,
reload=True,
reload_excludes=reload_excludes,
log_level="info",
access_log=False,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,73 @@
"""
Progress Broadcaster - Manages WebSocket broadcasting of knowledge base progress
"""
import asyncio
import logging
from typing import Optional
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class ProgressBroadcaster:
"""Manages WebSocket broadcasting of knowledge base progress"""
_instance: Optional["ProgressBroadcaster"] = None
_connections: dict[str, set[WebSocket]] = {} # kb_name -> Set[WebSocket]
_lock = asyncio.Lock()
@classmethod
def get_instance(cls) -> "ProgressBroadcaster":
"""Get singleton instance"""
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def connect(self, kb_name: str, websocket: WebSocket):
"""Connect WebSocket to specified knowledge base"""
async with self._lock:
if kb_name not in self._connections:
self._connections[kb_name] = set()
self._connections[kb_name].add(websocket)
logger.debug(
f"Connected WebSocket for KB '{kb_name}' (total: {len(self._connections[kb_name])})"
)
async def disconnect(self, kb_name: str, websocket: WebSocket):
"""Disconnect WebSocket connection"""
async with self._lock:
if kb_name in self._connections:
self._connections[kb_name].discard(websocket)
if not self._connections[kb_name]:
del self._connections[kb_name]
logger.debug(f"Disconnected WebSocket for KB '{kb_name}'")
async def broadcast(self, kb_name: str, progress: dict):
"""Broadcast progress update to all WebSocket connections for specified knowledge base"""
async with self._lock:
if kb_name not in self._connections:
return
# Create list of connections to remove (closed connections)
to_remove = []
for websocket in self._connections[kb_name]:
try:
await websocket.send_json({"type": "progress", "data": progress})
except Exception as e:
# Connection closed or error, mark for removal
logger.debug(f"Error sending to WebSocket for KB '{kb_name}': {e}")
to_remove.append(websocket)
# Remove closed connections
for ws in to_remove:
self._connections[kb_name].discard(ws)
if not self._connections[kb_name]:
del self._connections[kb_name]
def get_connection_count(self, kb_name: str) -> int:
"""Get connection count for specified knowledge base"""
return len(self._connections.get(kb_name, set()))
+103
View File
@@ -0,0 +1,103 @@
"""
Task ID Manager - Assigns unique IDs to each background task
"""
from datetime import datetime, timedelta
import logging
import threading
from typing import Optional
import uuid
logger = logging.getLogger(__name__)
class TaskIDManager:
"""Singleton class for managing task IDs"""
_instance: Optional["TaskIDManager"] = None
_lock = threading.Lock()
_task_ids: dict[str, str] = {} # task_key -> task_id
_task_metadata: dict[str, dict] = {} # task_id -> metadata
@classmethod
def get_instance(cls) -> "TaskIDManager":
"""Get singleton instance"""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def generate_task_id(self, task_type: str, task_key: str) -> str:
"""
Generate unique ID for task
Args:
task_type: Task type (e.g., 'kb_init', 'kb_upload', 'question_gen', 'solve', 'research')
task_key: Task unique identifier (e.g., knowledge base name, question ID, etc.)
Returns:
Task ID (format: {task_type}_{timestamp}_{uuid})
"""
with self._lock:
# If task already exists, return existing ID
if task_key in self._task_ids:
return self._task_ids[task_key]
# Generate new ID
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
unique_id = str(uuid.uuid4())[:8]
task_id = f"{task_type}_{timestamp}_{unique_id}"
# Save mapping and metadata
self._task_ids[task_key] = task_id
self._task_metadata[task_id] = {
"task_type": task_type,
"task_key": task_key,
"created_at": datetime.now().isoformat(),
"status": "running",
}
return task_id
def get_task_id(self, task_key: str) -> str | None:
"""Get task ID"""
with self._lock:
return self._task_ids.get(task_key)
def update_task_status(self, task_id: str, status: str, **kwargs):
"""Update task status"""
with self._lock:
if task_id in self._task_metadata:
self._task_metadata[task_id]["status"] = status
self._task_metadata[task_id].update(kwargs)
if status in ["completed", "error", "cancelled"]:
self._task_metadata[task_id]["finished_at"] = datetime.now().isoformat()
def get_task_metadata(self, task_id: str) -> dict | None:
"""Get task metadata"""
with self._lock:
return self._task_metadata.get(task_id, {}).copy()
def cleanup_old_tasks(self, max_age_hours: int = 24):
"""Clean up old tasks (completed tasks older than specified hours)"""
with self._lock:
cutoff = datetime.now() - timedelta(hours=max_age_hours)
to_remove = []
for task_id, metadata in self._task_metadata.items():
if metadata.get("status") in ["completed", "error", "cancelled"]:
finished_at = metadata.get("finished_at")
if finished_at:
try:
finished_time = datetime.fromisoformat(finished_at)
if finished_time < cutoff:
to_remove.append(task_id)
except Exception:
logger.warning("Failed to parse finished_at for task %s", task_id)
for task_id in to_remove:
metadata = self._task_metadata.pop(task_id, {})
task_key = metadata.get("task_key")
if task_key:
self._task_ids.pop(task_key, None)
+202
View File
@@ -0,0 +1,202 @@
import asyncio
from collections import deque
from collections.abc import AsyncGenerator
import contextlib
import importlib
import json
import logging
import threading
import time
from typing import Any
from deeptutor.logging import (
ProcessLogEvent,
bind_log_context,
capture_process_logs,
current_log_context,
)
def _format_sse(event: str, payload: dict[str, Any]) -> str:
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False, default=str)}\n\n"
class KnowledgeTaskStreamManager:
_instance: "KnowledgeTaskStreamManager | None" = None
_instance_lock = threading.Lock()
def __init__(self):
self._lock = threading.Lock()
self._buffers: dict[str, deque[dict[str, Any]]] = {}
self._subscribers: dict[str, list[tuple[asyncio.Queue, asyncio.AbstractEventLoop]]] = {}
@classmethod
def get_instance(cls) -> "KnowledgeTaskStreamManager":
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def ensure_task(self, task_id: str):
with self._lock:
self._buffers.setdefault(task_id, deque(maxlen=500))
self._subscribers.setdefault(task_id, [])
def emit(self, task_id: str, event: str, payload: dict[str, Any]):
event_payload = {"event": event, "payload": payload}
with self._lock:
self._buffers.setdefault(task_id, deque(maxlen=500)).append(event_payload)
subscribers = list(self._subscribers.get(task_id, []))
for queue, loop in subscribers:
try:
loop.call_soon_threadsafe(self._queue_event, queue, event_payload)
except RuntimeError:
continue
def emit_process_log(self, task_id: str, event: ProcessLogEvent):
payload = event.to_dict()
payload.setdefault("context", {})["task_id"] = task_id
self.emit(task_id, "process_log", payload)
def emit_log(self, task_id: str, line: str):
event = ProcessLogEvent(
level="INFO",
message=line,
logger="deeptutor.knowledge.task",
timestamp=time.time(),
context={"task_id": task_id, "capability": "knowledge", "sink": "ui"},
)
self.emit_process_log(task_id, event)
def emit_complete(self, task_id: str, detail: str = "Task completed"):
self.emit(task_id, "complete", {"detail": detail, "task_id": task_id})
def emit_failed(self, task_id: str, detail: str, *, details: str | None = None):
payload: dict[str, Any] = {"detail": detail, "task_id": task_id}
if details:
payload["details"] = details
self.emit(task_id, "failed", payload)
def subscribe(
self, task_id: str
) -> tuple[asyncio.Queue[dict[str, Any]], list[dict[str, Any]], asyncio.AbstractEventLoop]:
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=200)
loop = asyncio.get_running_loop()
with self._lock:
self._buffers.setdefault(task_id, deque(maxlen=500))
self._subscribers.setdefault(task_id, []).append((queue, loop))
backlog = list(self._buffers[task_id])
return queue, backlog, loop
def unsubscribe(
self, task_id: str, queue: asyncio.Queue[dict[str, Any]], loop: asyncio.AbstractEventLoop
):
with self._lock:
subscribers = self._subscribers.get(task_id, [])
self._subscribers[task_id] = [
(subscriber_queue, subscriber_loop)
for subscriber_queue, subscriber_loop in subscribers
if subscriber_queue is not queue or subscriber_loop is not loop
]
async def stream(self, task_id: str) -> AsyncGenerator[str, None]:
queue, backlog, loop = self.subscribe(task_id)
try:
for item in backlog:
yield _format_sse(item["event"], item["payload"])
if backlog and backlog[-1]["event"] in {"complete", "failed"}:
return
while True:
item = await queue.get()
yield _format_sse(item["event"], item["payload"])
if item["event"] in {"complete", "failed"}:
break
finally:
self.unsubscribe(task_id, queue, loop)
@staticmethod
def _queue_event(queue: asyncio.Queue[dict[str, Any]], payload: dict[str, Any]):
try:
queue.put_nowait(payload)
except asyncio.QueueFull:
pass
class _TaskScopedLogHandler(logging.Handler):
"""Forward non-propagating library logs into one knowledge task stream."""
def __init__(self, task_id: str, manager: KnowledgeTaskStreamManager) -> None:
super().__init__(logging.INFO)
self._task_id = task_id
self._manager = manager
def emit(self, record: logging.LogRecord) -> None:
try:
context = current_log_context()
record_task_id = context.get("task_id")
if record_task_id and record_task_id != self._task_id:
return
context.setdefault("task_id", self._task_id)
context.setdefault("capability", "knowledge")
context.setdefault("sink", "ui")
self._manager.emit_process_log(
self._task_id,
ProcessLogEvent(
level=record.levelname,
message=record.getMessage(),
logger=record.name,
timestamp=record.created,
context=context,
),
)
except Exception:
self.handleError(record)
@contextlib.contextmanager
def _capture_non_propagating_task_logs(task_id: str, manager: KnowledgeTaskStreamManager):
"""Capture library loggers that intentionally do not propagate to root."""
logger_names = ("lightrag", "graphrag", "graphrag_llm")
handlers: list[tuple[logging.Logger, _TaskScopedLogHandler]] = []
for logger_name in logger_names:
if logger_name == "lightrag":
with contextlib.suppress(Exception):
importlib.import_module("lightrag.utils")
source_logger = logging.getLogger(logger_name)
if source_logger.propagate:
continue
handler = _TaskScopedLogHandler(task_id, manager)
source_logger.addHandler(handler)
handlers.append((source_logger, handler))
try:
yield
finally:
for source_logger, handler in handlers:
if handler in source_logger.handlers:
source_logger.removeHandler(handler)
handler.close()
@contextlib.contextmanager
def capture_task_logs(task_id: str):
"""Forward all logs bound to ``task_id`` into the task's SSE stream."""
manager = KnowledgeTaskStreamManager.get_instance()
manager.ensure_task(task_id)
def emit(event: ProcessLogEvent) -> None:
manager.emit_process_log(task_id, event)
with bind_log_context(task_id=task_id, capability="knowledge", sink="ui"):
with capture_process_logs(emit, task_id=task_id):
with _capture_non_propagating_task_logs(task_id, manager):
yield
def get_task_stream_manager() -> KnowledgeTaskStreamManager:
return KnowledgeTaskStreamManager.get_instance()
+87
View File
@@ -0,0 +1,87 @@
"""Configurable-tool surface shared by the partners and multi-user admin APIs.
``tools`` mirrors the user-toggleable system tools (the same pool the chat
composer / settings expose); ``builtin_tools`` lists the auto-mounted built-in
tools (rag / read_memory / web_fetch / …) a partner owner can selectively
allow or deny; ``mcp_tools`` lists every configured MCP tool that a whitelist
(partner config or user grant) could allow.
"""
from __future__ import annotations
import logging
from typing import Any
from deeptutor.core.i18n import current_language
from deeptutor.i18n.metadata_i18n import localized_description, tool_description_i18n
logger = logging.getLogger(__name__)
async def build_tool_options(
*, exclude_builtin: set[str] | None = None
) -> dict[str, list[dict[str, Any]]]:
"""Build the configurable-tool surface.
``exclude_builtin`` drops built-in tools from the ``builtin_tools`` list —
the partners API passes ``{"read_memory", "write_memory"}`` because partners
use the mandatory ``partner_*`` memory tools instead and cannot configure
chat's memory tools.
"""
from deeptutor.agents._shared.tool_composition import default_optional_tools
from deeptutor.runtime.registry.tool_registry import get_tool_registry
from deeptutor.tools.builtin import CONFIGURABLE_BUILTIN_TOOL_NAMES
exclude = exclude_builtin or set()
registry = get_tool_registry()
language = current_language()
try:
from deeptutor.services.mcp import get_mcp_manager
await get_mcp_manager().ensure_started()
except Exception:
logger.debug("MCP manager unavailable for tool options", exc_info=True)
def _describe(name: str) -> dict[str, Any]:
tool = registry.get(name)
description = ""
if tool is not None:
try:
description = tool.get_definition().description or ""
except Exception:
description = ""
descriptions = tool_description_i18n(name, description)
return {
"name": name,
"description": localized_description(descriptions, language),
"description_i18n": descriptions,
}
tools: list[dict[str, Any]] = [_describe(name) for name in default_optional_tools()]
builtin_tools: list[dict[str, Any]] = [
_describe(name) for name in CONFIGURABLE_BUILTIN_TOOL_NAMES if name not in exclude
]
mcp_tools: list[dict[str, Any]] = []
for tool in registry.deferred_tools():
try:
definition = tool.get_definition()
except Exception:
continue
mcp_tools.append(
{
"name": definition.name,
"server": str(getattr(tool, "server_name", "") or ""),
"description": definition.description or "",
"description_i18n": {
"en": definition.description or "",
"zh": definition.description or "",
},
}
)
return {"tools": tools, "builtin_tools": builtin_tools, "mcp_tools": mcp_tools}
__all__ = ["build_tool_options"]