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.
- deeptutor/api/__init__.py +0 -0
- deeptutor/api/contracts/__init__.py +21 -0
- deeptutor/api/contracts/export.py +110 -0
- deeptutor/api/contracts/turn_protocol.py +313 -0
- deeptutor/api/main.py +547 -0
- deeptutor/api/module_routes.py +42 -0
- deeptutor/api/router_index.json +5752 -0
- deeptutor/api/router_loader.py +139 -0
- deeptutor/api/routers/__init__.py +1 -0
- deeptutor/api/routers/agent_config.py +59 -0
- deeptutor/api/routers/attachments.py +79 -0
- deeptutor/api/routers/auth.py +1937 -0
- deeptutor/api/routers/capabilities.py +44 -0
- deeptutor/api/routers/capabilities_settings.py +38 -0
- deeptutor/api/routers/dashboard.py +292 -0
- deeptutor/api/routers/file_library.py +169 -0
- deeptutor/api/routers/file_preview.py +205 -0
- deeptutor/api/routers/imports.py +146 -0
- deeptutor/api/routers/learning_journal.py +18 -0
- deeptutor/api/routers/mcp_settings.py +133 -0
- deeptutor/api/routers/modules.py +12 -0
- deeptutor/api/routers/multi_user.py +943 -0
- deeptutor/api/routers/outputs.py +97 -0
- deeptutor/api/routers/personas.py +154 -0
- deeptutor/api/routers/plugins.py +416 -0
- deeptutor/api/routers/quiz_judge.py +464 -0
- deeptutor/api/routers/sessions.py +801 -0
- deeptutor/api/routers/settings.py +2503 -0
- deeptutor/api/routers/skills.py +315 -0
- deeptutor/api/routers/space_cli_apps.py +222 -0
- deeptutor/api/routers/space_mcp.py +460 -0
- deeptutor/api/routers/subagents.py +306 -0
- deeptutor/api/routers/system.py +633 -0
- deeptutor/api/routers/task_board.py +139 -0
- deeptutor/api/routers/tools.py +268 -0
- deeptutor/api/routers/unified_ws.py +385 -0
- deeptutor/api/routers/workspace.py +371 -0
- deeptutor/api/run_server.py +99 -0
- deeptutor/api/security_guard.py +91 -0
- deeptutor/api/utils/http_headers.py +27 -0
- deeptutor/api/utils/progress_broadcaster.py +73 -0
- deeptutor/api/utils/task_id_manager.py +120 -0
- deeptutor/api/utils/task_log_stream.py +362 -0
- deeptutor/api/utils/tool_options.py +133 -0
- deeptutor_server-1.7.0.dist-info/METADATA +14 -0
- deeptutor_server-1.7.0.dist-info/RECORD +48 -0
- deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_server-1.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified session history API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
13
|
+
from pydantic import BaseModel, Field, field_validator
|
|
14
|
+
|
|
15
|
+
from deeptutor.core.turn_request import TurnRequest
|
|
16
|
+
from deeptutor.modules.catalog import module_enabled
|
|
17
|
+
from deeptutor.response_languages import validate_reply_language_override
|
|
18
|
+
from deeptutor.services.session import get_session_store, get_sqlite_session_store
|
|
19
|
+
from deeptutor.services.session.organization import (
|
|
20
|
+
list_all_sessions_snapshot,
|
|
21
|
+
validate_parent_assignment,
|
|
22
|
+
)
|
|
23
|
+
from deeptutor.services.session.provider_response_state import (
|
|
24
|
+
redact_private_message_metadata as _redact_provider_state_metadata,
|
|
25
|
+
)
|
|
26
|
+
from deeptutor.services.session.search import MAX_SEARCH_QUERY_CHARS
|
|
27
|
+
from deeptutor.services.storage.attachment_store import get_attachment_store
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
router = APIRouter()
|
|
32
|
+
_LEGACY_TIMED_MEDIA_ID = re.compile(r"^[0-9a-f]{16,64}$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _turn_application_service():
|
|
36
|
+
from deeptutor.app.container import get_application_container
|
|
37
|
+
|
|
38
|
+
return get_application_container().turns
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SessionStoreCapabilities(BaseModel):
|
|
42
|
+
submission_replacement: bool
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.get("/capabilities", response_model=SessionStoreCapabilities)
|
|
46
|
+
async def session_store_capabilities():
|
|
47
|
+
"""Expose operations the configured store can perform atomically."""
|
|
48
|
+
return SessionStoreCapabilities(
|
|
49
|
+
submission_replacement=callable(getattr(get_session_store(), "replace_submission", None))
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SubmissionReplacementRequest(BaseModel):
|
|
54
|
+
original_submission_id: str = Field(min_length=1, max_length=100)
|
|
55
|
+
request: TurnRequest
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SubmissionReplacementResponse(BaseModel):
|
|
59
|
+
status: Literal["started"]
|
|
60
|
+
session_id: str
|
|
61
|
+
submission_id: str
|
|
62
|
+
turn: dict[str, Any]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@router.post("/submission-replacement", response_model=SubmissionReplacementResponse)
|
|
66
|
+
async def replace_submission(body: SubmissionReplacementRequest):
|
|
67
|
+
"""Resolve admission atomically before an edited retry is sent."""
|
|
68
|
+
try:
|
|
69
|
+
return await _turn_application_service().replace_submission(
|
|
70
|
+
body.original_submission_id, body.request, start=True
|
|
71
|
+
)
|
|
72
|
+
except NotImplementedError as exc:
|
|
73
|
+
raise HTTPException(status_code=501, detail=str(exc)) from exc
|
|
74
|
+
except RuntimeError as exc:
|
|
75
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SessionRenameRequest(BaseModel):
|
|
79
|
+
title: str = Field(..., min_length=1, max_length=100)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class BranchSelectionRequest(BaseModel):
|
|
83
|
+
"""Edit-branch picker state: `{parent_message_id: chosen_child_id}`.
|
|
84
|
+
|
|
85
|
+
Stored inside the session preferences blob so it survives reloads
|
|
86
|
+
without a dedicated column.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
selected_branches: dict[str, int] = Field(default_factory=dict)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SessionOrganizationRequest(BaseModel):
|
|
93
|
+
"""User-controlled organization metadata stored with the conversation."""
|
|
94
|
+
|
|
95
|
+
workspace_id: str | None = None
|
|
96
|
+
parent_session_id: str | None = None
|
|
97
|
+
session_kind: Literal["chat", "selection_tutor", "immersive_reading"] | None = None
|
|
98
|
+
pinned: bool | None = None
|
|
99
|
+
archived: bool | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SessionReplyLanguageRequest(BaseModel):
|
|
103
|
+
language: str | None
|
|
104
|
+
|
|
105
|
+
@field_validator("language")
|
|
106
|
+
@classmethod
|
|
107
|
+
def _supported_language(cls, value: str | None) -> str | None:
|
|
108
|
+
return validate_reply_language_override(value)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class QuizResultItem(BaseModel):
|
|
112
|
+
question_id: str = ""
|
|
113
|
+
question: str = Field(..., min_length=1)
|
|
114
|
+
question_type: str = ""
|
|
115
|
+
options: dict[str, str] | None = None
|
|
116
|
+
user_answer: str = ""
|
|
117
|
+
correct_answer: str = ""
|
|
118
|
+
explanation: str | None = ""
|
|
119
|
+
difficulty: str | None = ""
|
|
120
|
+
is_correct: bool
|
|
121
|
+
|
|
122
|
+
@field_validator("options", mode="before")
|
|
123
|
+
@classmethod
|
|
124
|
+
def _coerce_options(cls, v):
|
|
125
|
+
return v if isinstance(v, dict) else {}
|
|
126
|
+
|
|
127
|
+
@field_validator("explanation", "difficulty", mode="before")
|
|
128
|
+
@classmethod
|
|
129
|
+
def _coerce_str(cls, v):
|
|
130
|
+
return v if isinstance(v, str) else ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class QuizResultsRequest(BaseModel):
|
|
134
|
+
answers: list[QuizResultItem] = Field(default_factory=list)
|
|
135
|
+
turn_id: str = ""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _format_quiz_results_message(answers: list[QuizResultItem]) -> str:
|
|
139
|
+
total = len(answers)
|
|
140
|
+
correct = sum(1 for item in answers if item.is_correct)
|
|
141
|
+
score_pct = round((correct / total) * 100) if total else 0
|
|
142
|
+
lines = ["[Quiz Performance]"]
|
|
143
|
+
for idx, item in enumerate(answers, 1):
|
|
144
|
+
question = item.question.strip().replace("\n", " ")
|
|
145
|
+
user_answer = (item.user_answer or "").strip() or "(blank)"
|
|
146
|
+
status = "Correct" if item.is_correct else "Incorrect"
|
|
147
|
+
suffix = f" ({status})"
|
|
148
|
+
if not item.is_correct and (item.correct_answer or "").strip():
|
|
149
|
+
suffix = f" ({status}, correct: {(item.correct_answer or '').strip()})"
|
|
150
|
+
qid = f"[{item.question_id}] " if item.question_id else ""
|
|
151
|
+
lines.append(f"{idx}. {qid}Q: {question} -> Answered: {user_answer}{suffix}")
|
|
152
|
+
lines.append(f"Score: {correct}/{total} ({score_pct}%)")
|
|
153
|
+
return "\n".join(lines)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _legacy_watching_media_id(preferences: dict[str, Any]) -> str:
|
|
157
|
+
watching = (
|
|
158
|
+
preferences.get("workspace_mode") == "immersive_watching"
|
|
159
|
+
or preferences.get("capability") == "immersive_watching"
|
|
160
|
+
or preferences.get("session_kind") == "immersive_watching"
|
|
161
|
+
)
|
|
162
|
+
if not watching:
|
|
163
|
+
return ""
|
|
164
|
+
candidate = str(preferences.get("timed_media_id") or "").strip().lower()
|
|
165
|
+
return candidate if _LEGACY_TIMED_MEDIA_ID.fullmatch(candidate) else ""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _normalize_legacy_watching_session(
|
|
169
|
+
store: Any,
|
|
170
|
+
session: dict[str, Any],
|
|
171
|
+
*,
|
|
172
|
+
persist: bool,
|
|
173
|
+
) -> bool:
|
|
174
|
+
preferences = session.get("preferences") if isinstance(session.get("preferences"), dict) else {}
|
|
175
|
+
from deeptutor.video_learning.service import TimedMediaNotFound
|
|
176
|
+
|
|
177
|
+
timed_media_id = _legacy_watching_media_id(preferences)
|
|
178
|
+
if not timed_media_id:
|
|
179
|
+
for message in reversed(session.get("messages") or []):
|
|
180
|
+
metadata = message.get("metadata") or {}
|
|
181
|
+
snapshot = metadata.get("request_snapshot") or metadata.get("requestSnapshot") or {}
|
|
182
|
+
recovered = snapshot.get("timedMediaId") or snapshot.get("timed_media_id")
|
|
183
|
+
candidate = _legacy_watching_media_id({**preferences, "timed_media_id": recovered})
|
|
184
|
+
if candidate:
|
|
185
|
+
timed_media_id = candidate
|
|
186
|
+
break
|
|
187
|
+
if not timed_media_id:
|
|
188
|
+
normalized = {
|
|
189
|
+
**preferences,
|
|
190
|
+
"capability": "chat",
|
|
191
|
+
"workspace_mode": "",
|
|
192
|
+
"session_kind": "chat",
|
|
193
|
+
"legacy_watching_unavailable": True,
|
|
194
|
+
}
|
|
195
|
+
session["preferences"] = normalized
|
|
196
|
+
if persist:
|
|
197
|
+
await store.update_session_preferences(str(session.get("id")), normalized)
|
|
198
|
+
return True
|
|
199
|
+
try:
|
|
200
|
+
from deeptutor.video_learning.reading_migration import WatchingToReadingMigration
|
|
201
|
+
|
|
202
|
+
migrated = await WatchingToReadingMigration().migrate(
|
|
203
|
+
timed_media_id,
|
|
204
|
+
session_id=str(session.get("id") or ""),
|
|
205
|
+
session_title=str(session.get("title") or "Imported video conversation"),
|
|
206
|
+
)
|
|
207
|
+
except TimedMediaNotFound:
|
|
208
|
+
normalized = {
|
|
209
|
+
**preferences,
|
|
210
|
+
"capability": "chat",
|
|
211
|
+
"workspace_mode": "",
|
|
212
|
+
"session_kind": "chat",
|
|
213
|
+
"legacy_watching_unavailable": True,
|
|
214
|
+
}
|
|
215
|
+
normalized.pop("timed_media_viewport", None)
|
|
216
|
+
session["preferences"] = normalized
|
|
217
|
+
if persist:
|
|
218
|
+
await store.update_session_preferences(str(session.get("id")), normalized)
|
|
219
|
+
return True
|
|
220
|
+
except Exception:
|
|
221
|
+
logger.warning(
|
|
222
|
+
"Could not migrate legacy Watching session %s to Reading",
|
|
223
|
+
session.get("id"),
|
|
224
|
+
exc_info=True,
|
|
225
|
+
)
|
|
226
|
+
return False
|
|
227
|
+
normalized = {
|
|
228
|
+
**preferences,
|
|
229
|
+
"capability": "chat",
|
|
230
|
+
"workspace_mode": "immersive_reading",
|
|
231
|
+
"session_kind": "immersive_reading",
|
|
232
|
+
"reading_workspace_id": migrated.reading_workspace_id,
|
|
233
|
+
"reading_material_id": migrated.reading_material_id,
|
|
234
|
+
# Kept as provenance and a provider-cache pointer; it is no longer routing state.
|
|
235
|
+
"timed_media_id": timed_media_id,
|
|
236
|
+
}
|
|
237
|
+
normalized.pop("timed_media_viewport", None)
|
|
238
|
+
session["preferences"] = normalized
|
|
239
|
+
if persist:
|
|
240
|
+
await store.update_session_preferences(str(session.get("id")), normalized)
|
|
241
|
+
return True
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@router.get("")
|
|
245
|
+
async def list_sessions(
|
|
246
|
+
limit: int = Query(default=50, ge=1, le=200),
|
|
247
|
+
offset: int = Query(default=0, ge=0),
|
|
248
|
+
workspace_id: str | None = Query(default=None),
|
|
249
|
+
all_workspaces: bool = False,
|
|
250
|
+
):
|
|
251
|
+
if all_workspaces:
|
|
252
|
+
# Account management (Settings › Archive) only; every navigation
|
|
253
|
+
# surface lists the request's own workspace.
|
|
254
|
+
from deeptutor.services.workspace.navigation import session_index
|
|
255
|
+
|
|
256
|
+
return await session_index(limit, offset)
|
|
257
|
+
store = get_session_store()
|
|
258
|
+
sessions = await store.list_sessions(
|
|
259
|
+
limit=limit,
|
|
260
|
+
offset=offset,
|
|
261
|
+
**({"workspace_id": workspace_id} if workspace_id is not None else {}),
|
|
262
|
+
)
|
|
263
|
+
return {"sessions": _with_source_workspace(sessions)}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _with_source_workspace(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
267
|
+
"""Name the store a row was read from; a legacy preference may disagree."""
|
|
268
|
+
from deeptutor.services.workspace.context import current_workspace_id
|
|
269
|
+
|
|
270
|
+
workspace_id = current_workspace_id()
|
|
271
|
+
return [{**row, "content_workspace_id": workspace_id} for row in rows]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@router.get("/search")
|
|
275
|
+
async def search_sessions(
|
|
276
|
+
q: str = Query(..., min_length=1, max_length=MAX_SEARCH_QUERY_CHARS),
|
|
277
|
+
limit: int = Query(default=50, ge=1, le=100),
|
|
278
|
+
offset: int = Query(default=0, ge=0),
|
|
279
|
+
):
|
|
280
|
+
"""Search titles and persisted user/assistant messages for a literal term."""
|
|
281
|
+
if not q.strip():
|
|
282
|
+
raise HTTPException(status_code=400, detail="Search query cannot be empty")
|
|
283
|
+
result = await get_session_store().search_sessions(q, limit=limit, offset=offset)
|
|
284
|
+
return {
|
|
285
|
+
"sessions": _with_source_workspace(result["sessions"]),
|
|
286
|
+
"total": result["total"],
|
|
287
|
+
"limit": limit,
|
|
288
|
+
"offset": offset,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# Cap (in characters) for a single event payload returned to the UI. RAG
|
|
293
|
+
# tools can attach whole KB documents to ``tool_result``/``observation``
|
|
294
|
+
# events; the frontend TraceSurface only needs a preview, and the LLM context
|
|
295
|
+
# is built from a separate content-only store, so capping here never affects
|
|
296
|
+
# model input.
|
|
297
|
+
MAX_EVENT_PAYLOAD = 1024 * 1024
|
|
298
|
+
_TRUNCATION_NOTICE = "\n\n[... content truncated]"
|
|
299
|
+
_TRUNCATABLE_EVENT_TYPES = ("tool_result", "observation")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _redact_private_message_metadata(messages: list[dict[str, Any]]) -> None:
|
|
303
|
+
"""Remove provider-only state before session details cross the API."""
|
|
304
|
+
_redact_provider_state_metadata(messages)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _attach_orphaned_failed_turns(
|
|
308
|
+
messages: list[dict[str, Any]], turns: list[dict[str, Any]]
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Expose a failed turn beside its saved user row when no reply exists.
|
|
311
|
+
|
|
312
|
+
Older rows have no turn id, so the first user row written during the
|
|
313
|
+
turn is the safe fallback. A failed preflight with no saved user row is
|
|
314
|
+
deliberately omitted rather than attached to an unrelated question.
|
|
315
|
+
"""
|
|
316
|
+
users = [
|
|
317
|
+
(index, message) for index, message in enumerate(messages) if message.get("role") == "user"
|
|
318
|
+
]
|
|
319
|
+
for turn in turns:
|
|
320
|
+
turn_id = str(turn.get("turn_id") or turn.get("id") or "")
|
|
321
|
+
started = float(turn.get("created_at") or 0)
|
|
322
|
+
finished = float(turn.get("finished_at") or turn.get("updated_at") or 0)
|
|
323
|
+
match = next(
|
|
324
|
+
(
|
|
325
|
+
(index, message)
|
|
326
|
+
for index, message in users
|
|
327
|
+
if str((message.get("metadata") or {}).get("turn_id") or "") == turn_id
|
|
328
|
+
),
|
|
329
|
+
None,
|
|
330
|
+
)
|
|
331
|
+
if match is None:
|
|
332
|
+
match = next(
|
|
333
|
+
(
|
|
334
|
+
(index, message)
|
|
335
|
+
for index, message in users
|
|
336
|
+
if started <= float(message.get("created_at") or 0) <= finished
|
|
337
|
+
),
|
|
338
|
+
None,
|
|
339
|
+
)
|
|
340
|
+
if match is None:
|
|
341
|
+
continue
|
|
342
|
+
user_index, user = match
|
|
343
|
+
next_user_index = next((index for index, _ in users if index > user_index), len(messages))
|
|
344
|
+
# A subsequent retry may have answered this same user row. Its real
|
|
345
|
+
# assistant bubble wins; showing an earlier failure beside it would
|
|
346
|
+
# tell the learner that the answered question is still broken. Legacy
|
|
347
|
+
# PocketBase rows have no parent link, so use their linear position
|
|
348
|
+
# only when the parent field is absent, and only after the failure.
|
|
349
|
+
if any(
|
|
350
|
+
message.get("role") == "assistant"
|
|
351
|
+
and (
|
|
352
|
+
(
|
|
353
|
+
str(message.get("parent_message_id") or "") == str(user.get("id") or "")
|
|
354
|
+
and float(message.get("created_at") or 0) >= started
|
|
355
|
+
)
|
|
356
|
+
or (
|
|
357
|
+
"parent_message_id" not in message
|
|
358
|
+
and user_index < index < next_user_index
|
|
359
|
+
and float(message.get("created_at") or 0) >= finished
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
for index, message in enumerate(messages)
|
|
363
|
+
):
|
|
364
|
+
continue
|
|
365
|
+
metadata = user.get("metadata")
|
|
366
|
+
if not isinstance(metadata, dict):
|
|
367
|
+
metadata = {}
|
|
368
|
+
user["metadata"] = metadata
|
|
369
|
+
previous = metadata.get("orphaned_failed_turn")
|
|
370
|
+
if isinstance(previous, dict) and float(previous.get("finished_at") or 0) > finished:
|
|
371
|
+
continue
|
|
372
|
+
metadata["orphaned_failed_turn"] = {
|
|
373
|
+
"turn_id": turn_id,
|
|
374
|
+
"error": str(turn.get("error") or ""),
|
|
375
|
+
"failure_code": str(turn.get("failure_code") or ""),
|
|
376
|
+
"retryable": bool(turn.get("retryable")),
|
|
377
|
+
"finished_at": finished,
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _truncate_oversized_events(
|
|
382
|
+
messages: list[dict[str, Any]], limit: int = MAX_EVENT_PAYLOAD
|
|
383
|
+
) -> None:
|
|
384
|
+
"""Cap oversized ``tool_result``/``observation`` payloads in place.
|
|
385
|
+
|
|
386
|
+
The session store already returns each message's events as a parsed
|
|
387
|
+
``events`` list (see ``SqliteSessionStore._serialize_message``), so we
|
|
388
|
+
mutate that list directly. Only the UI rendering path is affected.
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
def _cap(container: dict[str, Any], field: str) -> bool:
|
|
392
|
+
value = container.get(field)
|
|
393
|
+
if isinstance(value, str) and len(value) > limit:
|
|
394
|
+
container[field] = value[:limit] + _TRUNCATION_NOTICE
|
|
395
|
+
return True
|
|
396
|
+
return False
|
|
397
|
+
|
|
398
|
+
for msg in messages:
|
|
399
|
+
events = msg.get("events")
|
|
400
|
+
if not isinstance(events, list):
|
|
401
|
+
continue
|
|
402
|
+
for event in events:
|
|
403
|
+
if not isinstance(event, dict) or event.get("type") not in _TRUNCATABLE_EVENT_TYPES:
|
|
404
|
+
continue
|
|
405
|
+
truncated = _cap(event, "content")
|
|
406
|
+
tool_metadata = (event.get("metadata") or {}).get("tool_metadata")
|
|
407
|
+
if isinstance(tool_metadata, dict):
|
|
408
|
+
for field in ("content", "answer"):
|
|
409
|
+
truncated = _cap(tool_metadata, field) or truncated
|
|
410
|
+
if truncated:
|
|
411
|
+
event["_truncated"] = True
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@router.get("/recycle-bin")
|
|
415
|
+
async def list_recycle_bin(
|
|
416
|
+
limit: int = Query(default=50, ge=1, le=200),
|
|
417
|
+
offset: int = Query(default=0, ge=0),
|
|
418
|
+
):
|
|
419
|
+
store = get_session_store()
|
|
420
|
+
sessions = await store.list_deleted_sessions(limit=limit, offset=offset)
|
|
421
|
+
return {"sessions": sessions}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@router.post("/{session_id}/migrate-watching")
|
|
425
|
+
async def migrate_watching_session(session_id: str):
|
|
426
|
+
from deeptutor.services.workspace.activity import workspace_writer
|
|
427
|
+
|
|
428
|
+
@workspace_writer
|
|
429
|
+
async def migrate():
|
|
430
|
+
store = get_session_store()
|
|
431
|
+
session = await store.get_session_with_messages(session_id)
|
|
432
|
+
if session is None:
|
|
433
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
434
|
+
preferences = session.get("preferences") or {}
|
|
435
|
+
if any(
|
|
436
|
+
preferences.get(key) == "immersive_watching"
|
|
437
|
+
for key in ("capability", "workspace_mode", "session_kind")
|
|
438
|
+
):
|
|
439
|
+
if not await _normalize_legacy_watching_session(store, session, persist=True):
|
|
440
|
+
raise HTTPException(
|
|
441
|
+
status_code=409,
|
|
442
|
+
detail="The legacy video could not be opened in Reading. The original conversation is preserved.",
|
|
443
|
+
)
|
|
444
|
+
return await get_session(session_id)
|
|
445
|
+
|
|
446
|
+
return await migrate()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@router.get("/{session_id}")
|
|
450
|
+
async def get_session(session_id: str):
|
|
451
|
+
store = get_session_store()
|
|
452
|
+
session = await store.get_session_with_messages(session_id)
|
|
453
|
+
if session is None:
|
|
454
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
455
|
+
_attach_orphaned_failed_turns(
|
|
456
|
+
session.get("messages", []), await store.list_orphaned_failed_turns(session_id)
|
|
457
|
+
)
|
|
458
|
+
_redact_private_message_metadata(session.get("messages", []))
|
|
459
|
+
_truncate_oversized_events(session.get("messages", []))
|
|
460
|
+
return session
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@router.get("/{session_id}/organization")
|
|
464
|
+
async def get_session_organization(session_id: str):
|
|
465
|
+
"""Title, status and preferences without the transcript.
|
|
466
|
+
|
|
467
|
+
The chat header reads this to show the conversation's own menu (pinned,
|
|
468
|
+
archived, filed under) — refetching the full message list for that would
|
|
469
|
+
cost a long conversation its whole history on every sidebar change.
|
|
470
|
+
"""
|
|
471
|
+
store = get_session_store()
|
|
472
|
+
session = await store.get_session(session_id)
|
|
473
|
+
if session is None:
|
|
474
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
475
|
+
return {"session": session}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
@router.get("/{session_id}/threads")
|
|
479
|
+
async def list_session_threads(session_id: str):
|
|
480
|
+
"""Little Tutor threads opened from this conversation, newest first."""
|
|
481
|
+
store = get_session_store()
|
|
482
|
+
if await store.get_session(session_id) is None:
|
|
483
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
484
|
+
candidates = await list_all_sessions_snapshot(store)
|
|
485
|
+
threads = [
|
|
486
|
+
row
|
|
487
|
+
for row in candidates
|
|
488
|
+
if str((row.get("preferences") or {}).get("parent_session_id") or "") == session_id
|
|
489
|
+
and (row.get("preferences") or {}).get("session_kind") == "selection_tutor"
|
|
490
|
+
]
|
|
491
|
+
threads.sort(key=lambda row: float(row.get("updated_at") or 0), reverse=True)
|
|
492
|
+
return {"threads": _with_source_workspace(threads)}
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@router.get("/{session_id}/messages/{message_id}/events")
|
|
496
|
+
async def get_message_events(
|
|
497
|
+
session_id: str,
|
|
498
|
+
message_id: str,
|
|
499
|
+
after_seq: int = Query(default=0, ge=0),
|
|
500
|
+
limit: int = Query(default=500, ge=1, le=1000),
|
|
501
|
+
):
|
|
502
|
+
store = get_session_store()
|
|
503
|
+
trace = await store.get_message_trace(session_id, message_id, after_seq, limit)
|
|
504
|
+
if trace is None:
|
|
505
|
+
raise HTTPException(status_code=404, detail="Message not found")
|
|
506
|
+
return trace
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@router.get("/{session_id}/ask-hint")
|
|
510
|
+
async def get_session_ask_hint(session_id: str) -> dict[str, Any]:
|
|
511
|
+
"""One line the user is likely to type next, for the home composer placeholder."""
|
|
512
|
+
from deeptutor.services.chat_hints import get_ask_hint
|
|
513
|
+
|
|
514
|
+
return await get_ask_hint(session_id)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@router.patch("/{session_id}")
|
|
518
|
+
async def rename_session(session_id: str, payload: SessionRenameRequest):
|
|
519
|
+
store = get_session_store()
|
|
520
|
+
updated = await store.update_session_title(session_id, payload.title)
|
|
521
|
+
if not updated:
|
|
522
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
523
|
+
# The reader keeps its own list of a collection's conversations; the
|
|
524
|
+
# sidebar is where they are renamed, so the list has to hear about it.
|
|
525
|
+
try:
|
|
526
|
+
if module_enabled("reading"):
|
|
527
|
+
from deeptutor.reading.catalog_store import ReadingCatalogStore
|
|
528
|
+
|
|
529
|
+
await asyncio.to_thread(
|
|
530
|
+
ReadingCatalogStore().retitle_session, session_id, payload.title
|
|
531
|
+
)
|
|
532
|
+
except Exception:
|
|
533
|
+
logger.exception("failed to retitle reading conversation %s", session_id)
|
|
534
|
+
session = await store.get_session(session_id)
|
|
535
|
+
return {"session": session}
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@router.patch("/{session_id}/reply-language")
|
|
539
|
+
async def update_session_reply_language(session_id: str, payload: SessionReplyLanguageRequest):
|
|
540
|
+
"""Fix this conversation's reply language, or return to the account default."""
|
|
541
|
+
store = get_session_store()
|
|
542
|
+
if await store.get_session(session_id) is None:
|
|
543
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
544
|
+
updated = await store.update_session_preferences(
|
|
545
|
+
session_id, {"reply_language_override": payload.language}
|
|
546
|
+
)
|
|
547
|
+
session = await store.get_session(session_id)
|
|
548
|
+
if not updated or session is None:
|
|
549
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
550
|
+
return {"session": session}
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@router.patch("/{session_id}/organization")
|
|
554
|
+
async def update_session_organization(
|
|
555
|
+
session_id: str, payload: SessionOrganizationRequest, request: Request = None
|
|
556
|
+
):
|
|
557
|
+
store = get_session_store()
|
|
558
|
+
session = await store.get_session(session_id)
|
|
559
|
+
if session is None:
|
|
560
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
561
|
+
|
|
562
|
+
updates: dict[str, Any] = {}
|
|
563
|
+
fields = payload.model_fields_set
|
|
564
|
+
if "workspace_id" in fields:
|
|
565
|
+
from deeptutor.services.workspace import WorkspaceError, get_content_workspace_service
|
|
566
|
+
|
|
567
|
+
workspace_id = str(payload.workspace_id or "").strip()
|
|
568
|
+
if workspace_id:
|
|
569
|
+
try:
|
|
570
|
+
service = get_content_workspace_service()
|
|
571
|
+
service.validate_chat_binding(workspace_id)
|
|
572
|
+
if workspace_id == service.general_binding().workspace_id:
|
|
573
|
+
workspace_id = ""
|
|
574
|
+
except WorkspaceError as exc:
|
|
575
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
576
|
+
if await store.get_active_turn(session_id) is not None:
|
|
577
|
+
raise HTTPException(
|
|
578
|
+
status_code=409, detail="Wait for the active turn before moving this conversation."
|
|
579
|
+
)
|
|
580
|
+
if (session.get("preferences") or {}).get("parent_session_id"):
|
|
581
|
+
raise HTTPException(status_code=400, detail="Move the parent conversation instead.")
|
|
582
|
+
from deeptutor.services.workspace.context import get_workspace_scope
|
|
583
|
+
|
|
584
|
+
current_scope = get_workspace_scope()
|
|
585
|
+
if current_scope is not None and current_scope.workspace_id != workspace_id:
|
|
586
|
+
# A move changes the store. Do not silently drop other fields or
|
|
587
|
+
# validate their foreign keys against the source and apply them in
|
|
588
|
+
# the destination. Reject the whole request before copying data.
|
|
589
|
+
if fields - {"workspace_id"}:
|
|
590
|
+
raise HTTPException(
|
|
591
|
+
status_code=400,
|
|
592
|
+
detail="Move the conversation separately from other organization changes.",
|
|
593
|
+
)
|
|
594
|
+
from deeptutor.api.routers.workspace import _data_operation
|
|
595
|
+
from deeptutor.services.workspace.session_move import move_chat
|
|
596
|
+
|
|
597
|
+
# Upgrade this request's shared activity lease. The exclusive
|
|
598
|
+
# nonblocking lease rejects any concurrent writer before copying.
|
|
599
|
+
if request is not None:
|
|
600
|
+
handle = getattr(request.state, "workspace_activity", None)
|
|
601
|
+
if handle is not None:
|
|
602
|
+
handle.close()
|
|
603
|
+
request.state.workspace_activity = None
|
|
604
|
+
try:
|
|
605
|
+
moved = await _data_operation(move_chat, session_id, workspace_id)
|
|
606
|
+
except WorkspaceError as exc:
|
|
607
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
608
|
+
return {"session": moved}
|
|
609
|
+
updates["workspace_id"] = workspace_id or None
|
|
610
|
+
if "parent_session_id" in fields:
|
|
611
|
+
parent_id = str(payload.parent_session_id or "").strip()
|
|
612
|
+
if parent_id == session_id:
|
|
613
|
+
raise HTTPException(status_code=400, detail="A session cannot be its own parent")
|
|
614
|
+
if parent_id:
|
|
615
|
+
try:
|
|
616
|
+
await validate_parent_assignment(
|
|
617
|
+
store,
|
|
618
|
+
session_id=session_id,
|
|
619
|
+
parent_session_id=parent_id,
|
|
620
|
+
)
|
|
621
|
+
except LookupError as exc:
|
|
622
|
+
raise HTTPException(status_code=404, detail="Parent session not found") from exc
|
|
623
|
+
except ValueError as exc:
|
|
624
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
625
|
+
updates["parent_session_id"] = parent_id
|
|
626
|
+
if "session_kind" in fields:
|
|
627
|
+
updates["session_kind"] = payload.session_kind or "chat"
|
|
628
|
+
if "pinned" in fields:
|
|
629
|
+
updates["pinned"] = bool(payload.pinned)
|
|
630
|
+
if "archived" in fields:
|
|
631
|
+
updates["archived"] = bool(payload.archived)
|
|
632
|
+
|
|
633
|
+
if updates:
|
|
634
|
+
await store.update_session_preferences(session_id, updates)
|
|
635
|
+
cascade_updates = {
|
|
636
|
+
key: updates[key] for key in ("archived", "workspace_id") if key in updates
|
|
637
|
+
}
|
|
638
|
+
if cascade_updates:
|
|
639
|
+
# Selected-text tutor threads stay with their source conversation.
|
|
640
|
+
candidates = await list_all_sessions_snapshot(store)
|
|
641
|
+
for candidate in candidates:
|
|
642
|
+
prefs = candidate.get("preferences") or {}
|
|
643
|
+
if str(prefs.get("parent_session_id") or "") == session_id:
|
|
644
|
+
await store.update_session_preferences(candidate["session_id"], cascade_updates)
|
|
645
|
+
refreshed = await store.get_session(session_id)
|
|
646
|
+
return {"session": refreshed}
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
@router.delete("/{session_id}")
|
|
650
|
+
async def delete_session(session_id: str):
|
|
651
|
+
store = get_session_store()
|
|
652
|
+
if await store.get_session(session_id) is None:
|
|
653
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
654
|
+
|
|
655
|
+
# Tutor threads belong to their parent conversation. Snapshot before any
|
|
656
|
+
# deletion changes pagination, and walk descendants without revisiting cycles.
|
|
657
|
+
candidates = await list_all_sessions_snapshot(store)
|
|
658
|
+
children: dict[str, list[str]] = {}
|
|
659
|
+
for row in candidates:
|
|
660
|
+
parent = str((row.get("preferences") or {}).get("parent_session_id") or "")
|
|
661
|
+
children.setdefault(parent, []).append(row["session_id"])
|
|
662
|
+
ordered = [session_id]
|
|
663
|
+
seen = {session_id}
|
|
664
|
+
for parent in ordered:
|
|
665
|
+
for child in children.get(parent, []):
|
|
666
|
+
if child not in seen:
|
|
667
|
+
seen.add(child)
|
|
668
|
+
ordered.append(child)
|
|
669
|
+
|
|
670
|
+
list_active_turns = getattr(store, "list_active_turns", None)
|
|
671
|
+
if callable(list_active_turns):
|
|
672
|
+
turns = _turn_application_service()
|
|
673
|
+
for target in ordered:
|
|
674
|
+
for turn in await list_active_turns(target):
|
|
675
|
+
if not await turns.cancel_turn_and_wait(turn["id"]):
|
|
676
|
+
raise HTTPException(
|
|
677
|
+
status_code=409,
|
|
678
|
+
detail="Active conversation turn could not be stopped before deletion",
|
|
679
|
+
)
|
|
680
|
+
for target in reversed(ordered):
|
|
681
|
+
if not await store.delete_session(target):
|
|
682
|
+
raise HTTPException(status_code=409, detail="Unable to delete conversation")
|
|
683
|
+
await _cleanup_deleted_session(target)
|
|
684
|
+
return {"deleted": True, "session_id": session_id}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
async def _cleanup_deleted_session(session_id: str) -> None:
|
|
688
|
+
try:
|
|
689
|
+
if module_enabled("mastery"):
|
|
690
|
+
from deeptutor.learning.storage import LearningStore
|
|
691
|
+
|
|
692
|
+
await asyncio.to_thread(LearningStore().detach_session, session_id)
|
|
693
|
+
except Exception:
|
|
694
|
+
logger.exception("failed to detach mastery paths for session %s", session_id)
|
|
695
|
+
try:
|
|
696
|
+
await get_attachment_store().delete_session(session_id)
|
|
697
|
+
except Exception:
|
|
698
|
+
logger.exception("failed to clean up attachments for session %s", session_id)
|
|
699
|
+
try:
|
|
700
|
+
if module_enabled("reading"):
|
|
701
|
+
from deeptutor.reading.catalog_store import ReadingCatalogStore
|
|
702
|
+
|
|
703
|
+
await asyncio.to_thread(ReadingCatalogStore().forget_session, session_id)
|
|
704
|
+
except Exception:
|
|
705
|
+
logger.exception("failed to detach reading collections for session %s", session_id)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@router.post("/{session_id}/restore")
|
|
709
|
+
async def restore_session(session_id: str):
|
|
710
|
+
store = get_session_store()
|
|
711
|
+
restored = await store.restore_session(session_id)
|
|
712
|
+
if not restored:
|
|
713
|
+
raise HTTPException(status_code=404, detail="Session not found in recycle bin")
|
|
714
|
+
refreshed = await store.get_session(session_id)
|
|
715
|
+
return {"restored": True, "session": refreshed}
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
@router.delete("/{session_id}/purge")
|
|
719
|
+
async def purge_session(session_id: str):
|
|
720
|
+
store = get_session_store()
|
|
721
|
+
purged = await store.hard_delete_session(session_id)
|
|
722
|
+
if not purged:
|
|
723
|
+
raise HTTPException(status_code=404, detail="Session not found in recycle bin")
|
|
724
|
+
await _cleanup_deleted_session(session_id)
|
|
725
|
+
from deeptutor.services.task_board import get_task_board_store
|
|
726
|
+
from deeptutor.services.workspace.context import current_workspace_id
|
|
727
|
+
|
|
728
|
+
await asyncio.to_thread(
|
|
729
|
+
get_task_board_store(migrate_legacy=False).remove_session,
|
|
730
|
+
current_workspace_id(),
|
|
731
|
+
session_id,
|
|
732
|
+
)
|
|
733
|
+
return {"purged": True, "session_id": session_id}
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
@router.put("/{session_id}/branch-selection")
|
|
737
|
+
async def update_branch_selection(session_id: str, payload: BranchSelectionRequest):
|
|
738
|
+
store = get_sqlite_session_store()
|
|
739
|
+
session = await store.get_session(session_id)
|
|
740
|
+
if session is None:
|
|
741
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
742
|
+
updated = await store.update_session_preferences(
|
|
743
|
+
session_id, {"selected_branches": dict(payload.selected_branches)}
|
|
744
|
+
)
|
|
745
|
+
if not updated:
|
|
746
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
747
|
+
return {"selected_branches": payload.selected_branches}
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
@router.delete("/{session_id}/messages/{message_id}")
|
|
751
|
+
async def delete_turn_by_message(session_id: str, message_id: int):
|
|
752
|
+
store = get_sqlite_session_store()
|
|
753
|
+
result = await store.delete_turn_by_message(session_id, message_id)
|
|
754
|
+
if result["was_active"]:
|
|
755
|
+
raise HTTPException(
|
|
756
|
+
status_code=409,
|
|
757
|
+
detail="Cannot delete a message while its turn is running or waiting for input",
|
|
758
|
+
)
|
|
759
|
+
if not result["deleted"]:
|
|
760
|
+
raise HTTPException(status_code=404, detail="Message not found")
|
|
761
|
+
attachment_store = get_attachment_store()
|
|
762
|
+
for aid in result["attachment_ids"]:
|
|
763
|
+
try:
|
|
764
|
+
await attachment_store.delete_attachment(session_id, aid)
|
|
765
|
+
except Exception:
|
|
766
|
+
logger.exception("failed to delete attachment %s for session %s", aid, session_id)
|
|
767
|
+
return result
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
@router.post("/{session_id}/quiz-results")
|
|
771
|
+
async def record_quiz_results(session_id: str, payload: QuizResultsRequest):
|
|
772
|
+
if not payload.answers:
|
|
773
|
+
raise HTTPException(status_code=400, detail="Quiz results are required")
|
|
774
|
+
store = get_sqlite_session_store()
|
|
775
|
+
session = await store.get_session(session_id)
|
|
776
|
+
if session is None:
|
|
777
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
778
|
+
content = _format_quiz_results_message(payload.answers)
|
|
779
|
+
await store.add_message(
|
|
780
|
+
session_id=session_id,
|
|
781
|
+
role="user",
|
|
782
|
+
content=content,
|
|
783
|
+
capability="deep_question",
|
|
784
|
+
)
|
|
785
|
+
notebook_count = 0
|
|
786
|
+
try:
|
|
787
|
+
notebook_count = await store.upsert_notebook_entries(
|
|
788
|
+
session_id,
|
|
789
|
+
[{**item.model_dump(), "turn_id": payload.turn_id} for item in payload.answers],
|
|
790
|
+
)
|
|
791
|
+
except Exception:
|
|
792
|
+
logger.warning(
|
|
793
|
+
"Failed to upsert notebook entries for session %s", session_id, exc_info=True
|
|
794
|
+
)
|
|
795
|
+
return {
|
|
796
|
+
"recorded": True,
|
|
797
|
+
"session_id": session_id,
|
|
798
|
+
"answer_count": len(payload.answers),
|
|
799
|
+
"notebook_count": notebook_count,
|
|
800
|
+
"content": content,
|
|
801
|
+
}
|