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,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
+ }