deeptutor-server 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. deeptutor/api/__init__.py +0 -0
  2. deeptutor/api/contracts/__init__.py +21 -0
  3. deeptutor/api/contracts/export.py +110 -0
  4. deeptutor/api/contracts/turn_protocol.py +313 -0
  5. deeptutor/api/main.py +547 -0
  6. deeptutor/api/module_routes.py +42 -0
  7. deeptutor/api/router_index.json +5752 -0
  8. deeptutor/api/router_loader.py +139 -0
  9. deeptutor/api/routers/__init__.py +1 -0
  10. deeptutor/api/routers/agent_config.py +59 -0
  11. deeptutor/api/routers/attachments.py +79 -0
  12. deeptutor/api/routers/auth.py +1937 -0
  13. deeptutor/api/routers/capabilities.py +44 -0
  14. deeptutor/api/routers/capabilities_settings.py +38 -0
  15. deeptutor/api/routers/dashboard.py +292 -0
  16. deeptutor/api/routers/file_library.py +169 -0
  17. deeptutor/api/routers/file_preview.py +205 -0
  18. deeptutor/api/routers/imports.py +146 -0
  19. deeptutor/api/routers/learning_journal.py +18 -0
  20. deeptutor/api/routers/mcp_settings.py +133 -0
  21. deeptutor/api/routers/modules.py +12 -0
  22. deeptutor/api/routers/multi_user.py +943 -0
  23. deeptutor/api/routers/outputs.py +97 -0
  24. deeptutor/api/routers/personas.py +154 -0
  25. deeptutor/api/routers/plugins.py +416 -0
  26. deeptutor/api/routers/quiz_judge.py +464 -0
  27. deeptutor/api/routers/sessions.py +801 -0
  28. deeptutor/api/routers/settings.py +2503 -0
  29. deeptutor/api/routers/skills.py +315 -0
  30. deeptutor/api/routers/space_cli_apps.py +222 -0
  31. deeptutor/api/routers/space_mcp.py +460 -0
  32. deeptutor/api/routers/subagents.py +306 -0
  33. deeptutor/api/routers/system.py +633 -0
  34. deeptutor/api/routers/task_board.py +139 -0
  35. deeptutor/api/routers/tools.py +268 -0
  36. deeptutor/api/routers/unified_ws.py +385 -0
  37. deeptutor/api/routers/workspace.py +371 -0
  38. deeptutor/api/run_server.py +99 -0
  39. deeptutor/api/security_guard.py +91 -0
  40. deeptutor/api/utils/http_headers.py +27 -0
  41. deeptutor/api/utils/progress_broadcaster.py +73 -0
  42. deeptutor/api/utils/task_id_manager.py +120 -0
  43. deeptutor/api/utils/task_log_stream.py +362 -0
  44. deeptutor/api/utils/tool_options.py +133 -0
  45. deeptutor_server-1.7.0.dist-info/METADATA +14 -0
  46. deeptutor_server-1.7.0.dist-info/RECORD +48 -0
  47. deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
  48. deeptutor_server-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,139 @@
1
+ """Each workspace's task board, kept in one account store.
2
+
3
+ The router sits outside request scope installation (see ``auth.py``) so its
4
+ event stream never holds a workspace activity lease; it resolves the board's
5
+ workspace from the same header/query itself.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+
12
+ from fastapi import APIRouter, HTTPException, Request
13
+ from fastapi.responses import StreamingResponse
14
+
15
+ from deeptutor.services.task_board import (
16
+ CreateCard,
17
+ LinkStatus,
18
+ LinkTasks,
19
+ StatusColors,
20
+ TaskBoard,
21
+ UpdateCard,
22
+ get_task_board_store,
23
+ workspace_board,
24
+ )
25
+ from deeptutor.services.workspace.context import workspace_context
26
+ from deeptutor.services.workspace.models import WorkspaceError
27
+
28
+ router = APIRouter()
29
+
30
+
31
+ def board_workspace(request: Request) -> str:
32
+ raw = request.headers.get("x-deeptutor-workspace") or request.query_params.get("dt_workspace")
33
+ try:
34
+ with workspace_context(raw or "") as scope:
35
+ return scope.workspace_id
36
+ except WorkspaceError as exc:
37
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
38
+
39
+
40
+ @router.get("", response_model=TaskBoard)
41
+ def get_board(request: Request) -> TaskBoard:
42
+ return workspace_board(get_task_board_store().read(), board_workspace(request))
43
+
44
+
45
+ @router.get("/events")
46
+ async def board_events(request: Request):
47
+ # Resolve once before streaming; each connection retains its account identity.
48
+ workspace_id = board_workspace(request)
49
+ store = await asyncio.to_thread(get_task_board_store)
50
+
51
+ async def events():
52
+ revision = -1
53
+ heartbeat = 0
54
+ while not await request.is_disconnected():
55
+ current_revision = await asyncio.to_thread(store.revision)
56
+ if current_revision != revision:
57
+ board = workspace_board(await asyncio.to_thread(store.read), workspace_id)
58
+ revision = board.revision
59
+ yield f"id: {revision}\ndata: {board.model_dump_json()}\n\n"
60
+ elif heartbeat % 30 == 0:
61
+ yield ": heartbeat\n\n"
62
+ heartbeat += 1
63
+ await asyncio.sleep(0.5)
64
+
65
+ return StreamingResponse(
66
+ events(),
67
+ media_type="text/event-stream",
68
+ headers={
69
+ # Next's proxy must flush each event instead of gzip-buffering it.
70
+ "Cache-Control": "no-cache, no-transform",
71
+ "Content-Encoding": "identity",
72
+ "X-Accel-Buffering": "no",
73
+ },
74
+ )
75
+
76
+
77
+ @router.post("/cards", response_model=TaskBoard, status_code=201)
78
+ def create_card(payload: CreateCard, request: Request) -> TaskBoard:
79
+ workspace_id = board_workspace(request)
80
+ return workspace_board(get_task_board_store().create(payload, workspace_id), workspace_id)
81
+
82
+
83
+ @router.patch("/cards/{card_id}", response_model=TaskBoard)
84
+ def update_card(card_id: str, payload: UpdateCard, request: Request) -> TaskBoard:
85
+ workspace_id = board_workspace(request)
86
+ try:
87
+ if "workspace_id" in payload.model_fields_set and payload.workspace_id is not None:
88
+ with workspace_context(payload.workspace_id) as scope:
89
+ payload.workspace_id = scope.workspace_id
90
+ if scope.archived:
91
+ raise WorkspaceError("Restore this workspace before assigning tasks.")
92
+ return workspace_board(
93
+ get_task_board_store().update(card_id, payload, workspace_id=workspace_id), workspace_id
94
+ )
95
+ except WorkspaceError as exc:
96
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
97
+ except KeyError as exc:
98
+ raise HTTPException(status_code=404, detail="Task card not found.") from exc
99
+
100
+
101
+ @router.put("/colors", response_model=TaskBoard)
102
+ def update_colors(payload: StatusColors, request: Request) -> TaskBoard:
103
+ workspace_id = board_workspace(request)
104
+ return workspace_board(get_task_board_store().set_colors(payload), workspace_id)
105
+
106
+
107
+ async def require_session(session_id: str, workspace_id: str, board_workspace_id: str) -> str:
108
+ from deeptutor.services.session import get_session_store
109
+
110
+ try:
111
+ with workspace_context(workspace_id) as scope:
112
+ if scope.workspace_id != board_workspace_id:
113
+ raise HTTPException(status_code=404, detail="Conversation not found.")
114
+ if scope.archived:
115
+ raise WorkspaceError("Restore this workspace before changing its conversations.")
116
+ if await get_session_store().get_session(session_id) is None:
117
+ raise HTTPException(status_code=404, detail="Conversation not found.")
118
+ return scope.workspace_id
119
+ except WorkspaceError as exc:
120
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
121
+
122
+
123
+ @router.put("/sessions/{session_id}", response_model=TaskBoard)
124
+ async def link_tasks(session_id: str, payload: LinkTasks, request: Request) -> TaskBoard:
125
+ workspace_id = board_workspace(request)
126
+ payload.workspace_id = await require_session(session_id, payload.workspace_id, workspace_id)
127
+ try:
128
+ board = await asyncio.to_thread(get_task_board_store().link_tasks, session_id, payload)
129
+ return workspace_board(board, workspace_id)
130
+ except KeyError as exc:
131
+ raise HTTPException(status_code=404, detail="Task not found or archived.") from exc
132
+
133
+
134
+ @router.patch("/sessions/{session_id}/status", response_model=TaskBoard)
135
+ async def link_status(session_id: str, payload: LinkStatus, request: Request) -> TaskBoard:
136
+ workspace_id = board_workspace(request)
137
+ payload.workspace_id = await require_session(session_id, payload.workspace_id, workspace_id)
138
+ board = await asyncio.to_thread(get_task_board_store().link_status, session_id, payload)
139
+ return workspace_board(board, workspace_id)
@@ -0,0 +1,268 @@
1
+ """
2
+ Tools API Router
3
+ ================
4
+
5
+ Read-only listing of the chat agent's built-in tools, used by the Settings UI
6
+ to render the "Tools" sub-page. Returns each tool's definition (name,
7
+ description, parameters) alongside its bilingual prompt hints, so the frontend
8
+ can show authoritative copy without duplicating the catalog.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import Any, Literal
15
+
16
+ from fastapi import APIRouter
17
+ from pydantic import BaseModel
18
+
19
+ from deeptutor.core.tool_protocol import BaseTool, ToolDefinition, ToolPromptHints
20
+ from deeptutor.i18n.metadata_i18n import tool_description_i18n
21
+ from deeptutor.services.config import resolve_search_runtime_config
22
+ from deeptutor.services.settings.interface_settings import get_enabled_optional_tools
23
+ from deeptutor.tools.builtin import (
24
+ BUILTIN_TOOL_TYPES,
25
+ COMING_SOON_TOOL_TYPES,
26
+ TOOL_ALIASES,
27
+ USER_TOGGLEABLE_TOOL_NAMES,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ router = APIRouter()
33
+
34
+
35
+ class ToolParameterPayload(BaseModel):
36
+ name: str
37
+ type: str
38
+ description: str = ""
39
+ required: bool = True
40
+ default: Any = None
41
+ enum: list[str] | None = None
42
+
43
+
44
+ class ToolAliasPayload(BaseModel):
45
+ name: str
46
+ description: str = ""
47
+ input_format: str = ""
48
+ when_to_use: str = ""
49
+ phase: str = ""
50
+
51
+
52
+ class ToolHintsPayload(BaseModel):
53
+ short_description: str = ""
54
+ when_to_use: str = ""
55
+ input_format: str = ""
56
+ guideline: str = ""
57
+ note: str = ""
58
+ phase: str = ""
59
+ aliases: list[ToolAliasPayload] = []
60
+
61
+
62
+ class BuiltinToolPayload(BaseModel):
63
+ name: str
64
+ description: str
65
+ description_i18n: dict[Literal["en", "zh"], str] = {}
66
+ parameters: list[ToolParameterPayload]
67
+ hints: dict[Literal["en", "zh"], ToolHintsPayload]
68
+ aliases: list[str] = []
69
+ # True iff the user is allowed to switch this tool on/off from the
70
+ # /settings/tools UI. Locked-on tools (auto-mounted by the chat
71
+ # pipeline under context gates) report ``False`` and the UI renders
72
+ # them as informational entries only.
73
+ toggleable: bool = False
74
+ # Whether the tool is currently on. For toggleable tools this
75
+ # reflects the user's saved preference; for locked-on tools this is
76
+ # always ``True``.
77
+ enabled: bool = True
78
+ # ``coming_soon`` tools are NOT registered with the runtime — the chat
79
+ # agent cannot invoke them. They are listed here only so the settings
80
+ # page can render a placeholder card explaining the capability is on
81
+ # the roadmap. The frontend should lock the toggle and show a badge.
82
+ coming_soon: bool = False
83
+ # The capability that owns this tool (e.g. ``solve`` / ``mastery``), or
84
+ # ``None`` for a plain system built-in. Owned tools are reused by their
85
+ # capability on top of the shared built-in surface; the settings UI groups
86
+ # them under their owner, below the built-in section.
87
+ capability: str | None = None
88
+ # Runtime readiness is separate from the saved on/off preference. A tool
89
+ # may be enabled in the composer while its backing service still needs
90
+ # configuration (notably web_search with provider="none").
91
+ available: bool = True
92
+ unavailable_reason: str | None = None
93
+
94
+
95
+ class ToolsListResponse(BaseModel):
96
+ tools: list[BuiltinToolPayload]
97
+ enabled_optional_tools: list[str]
98
+
99
+
100
+ def _serialise_definition(
101
+ definition: ToolDefinition,
102
+ ) -> tuple[str, str, list[ToolParameterPayload]]:
103
+ params = [
104
+ ToolParameterPayload(
105
+ name=p.name,
106
+ type=p.type,
107
+ description=p.description,
108
+ required=p.required,
109
+ default=p.default,
110
+ enum=p.enum,
111
+ )
112
+ for p in definition.parameters
113
+ ]
114
+ if not params and isinstance(definition.raw_parameters, dict):
115
+ properties = definition.raw_parameters.get("properties")
116
+ required = definition.raw_parameters.get("required")
117
+ required_names = set(required) if isinstance(required, list) else set()
118
+ if isinstance(properties, dict):
119
+ for name, raw in properties.items():
120
+ schema = raw if isinstance(raw, dict) else {}
121
+ enum = schema.get("enum")
122
+ params.append(
123
+ ToolParameterPayload(
124
+ name=str(name),
125
+ type=str(schema.get("type") or "object"),
126
+ description=str(schema.get("description") or ""),
127
+ required=str(name) in required_names,
128
+ default=schema.get("default"),
129
+ enum=([str(value) for value in enum] if isinstance(enum, list) else None),
130
+ )
131
+ )
132
+ return definition.name, definition.description, params
133
+
134
+
135
+ def _serialise_hints(hints: ToolPromptHints) -> ToolHintsPayload:
136
+ return ToolHintsPayload(
137
+ short_description=hints.short_description,
138
+ when_to_use=hints.when_to_use,
139
+ input_format=hints.input_format,
140
+ guideline=hints.guideline,
141
+ note=hints.note,
142
+ phase=hints.phase,
143
+ aliases=[
144
+ ToolAliasPayload(
145
+ name=alias.name,
146
+ description=alias.description,
147
+ input_format=alias.input_format,
148
+ when_to_use=alias.when_to_use,
149
+ phase=alias.phase,
150
+ )
151
+ for alias in hints.aliases
152
+ ],
153
+ )
154
+
155
+
156
+ def _collect_aliases_for(tool_name: str) -> list[str]:
157
+ return sorted(alias for alias, (target, _) in TOOL_ALIASES.items() if target == tool_name)
158
+
159
+
160
+ def _build_tool_payload(
161
+ tool: BaseTool,
162
+ *,
163
+ enabled_optional: set[str],
164
+ coming_soon: bool = False,
165
+ capability: str | None = None,
166
+ ) -> BuiltinToolPayload:
167
+ name, description, parameters = _serialise_definition(tool.get_definition())
168
+ descriptions = tool_description_i18n(name, description)
169
+ toggleable = (not coming_soon) and (name in USER_TOGGLEABLE_TOOL_NAMES)
170
+ if coming_soon:
171
+ enabled = False
172
+ elif toggleable:
173
+ enabled = name in enabled_optional
174
+ else:
175
+ enabled = True
176
+ available, unavailable_reason = _runtime_availability(name, coming_soon=coming_soon)
177
+ return BuiltinToolPayload(
178
+ name=name,
179
+ description=descriptions.get("en") or description,
180
+ description_i18n=descriptions,
181
+ parameters=parameters,
182
+ hints={
183
+ "en": _serialise_hints(tool.get_prompt_hints(language="en")),
184
+ "zh": _serialise_hints(tool.get_prompt_hints(language="zh")),
185
+ },
186
+ aliases=_collect_aliases_for(name),
187
+ toggleable=toggleable,
188
+ enabled=enabled,
189
+ coming_soon=coming_soon,
190
+ capability=capability,
191
+ available=available,
192
+ unavailable_reason=unavailable_reason,
193
+ )
194
+
195
+
196
+ def _runtime_availability(name: str, *, coming_soon: bool = False) -> tuple[bool, str | None]:
197
+ if coming_soon:
198
+ return False, "coming_soon"
199
+ if name != "web_search":
200
+ return True, None
201
+ try:
202
+ config = resolve_search_runtime_config()
203
+ except Exception:
204
+ logger.exception("Failed to resolve web_search runtime availability")
205
+ return False, "search_configuration_error"
206
+ if config.provider == "none":
207
+ return False, "search_provider_not_configured"
208
+ if config.unsupported_provider or config.deprecated_provider:
209
+ return False, "search_provider_unsupported"
210
+ if config.missing_credentials:
211
+ return False, "search_credentials_missing"
212
+ return True, None
213
+
214
+
215
+ @router.get("", response_model=ToolsListResponse)
216
+ async def list_builtin_tools() -> ToolsListResponse:
217
+ """Return all built-in tools the chat agent can invoke, plus any
218
+ coming-soon placeholders for the settings page."""
219
+ from deeptutor.capabilities import capability_tool_owners
220
+
221
+ enabled_optional = set(get_enabled_optional_tools())
222
+ owners = capability_tool_owners()
223
+ payloads: list[BuiltinToolPayload] = []
224
+ for tool_type in BUILTIN_TOOL_TYPES:
225
+ try:
226
+ instance = tool_type()
227
+ payloads.append(
228
+ _build_tool_payload(
229
+ instance,
230
+ enabled_optional=enabled_optional,
231
+ capability=owners.get(instance.name),
232
+ )
233
+ )
234
+ except Exception:
235
+ logger.exception("Failed to serialise tool %s", tool_type.__name__)
236
+ for tool_type in COMING_SOON_TOOL_TYPES:
237
+ try:
238
+ instance = tool_type()
239
+ payloads.append(
240
+ _build_tool_payload(
241
+ instance,
242
+ enabled_optional=enabled_optional,
243
+ coming_soon=True,
244
+ )
245
+ )
246
+ except Exception:
247
+ logger.exception("Failed to serialise coming-soon tool %s", tool_type.__name__)
248
+ # Guard against the unlikely case of name collision (e.g. someone
249
+ # accidentally registers the same tool both as built-in and coming-soon).
250
+ seen: set[str] = set()
251
+ deduped: list[BuiltinToolPayload] = []
252
+ for payload in payloads:
253
+ if payload.name in seen:
254
+ continue
255
+ seen.add(payload.name)
256
+ deduped.append(payload)
257
+ # Toggleable tools outside the user's admin grant don't exist for them:
258
+ # hidden here so the settings page and composer match what turn_runtime
259
+ # will actually allow.
260
+ from deeptutor.multi_user.tool_access import allowed_optional_tools
261
+
262
+ allowed = allowed_optional_tools()
263
+ if allowed is not None:
264
+ deduped = [p for p in deduped if not p.toggleable or p.name in allowed]
265
+ return ToolsListResponse(
266
+ tools=deduped,
267
+ enabled_optional_tools=sorted(enabled_optional),
268
+ )