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,464 @@
1
+ """AI judge WebSocket — grades a learner's quiz answer.
2
+
3
+ Mounted on its own (without router-level HTTP auth dependencies) because
4
+ WebSocket upgrades cannot use FastAPI's HTTP dependency injection, so we
5
+ rely on ``ws_require_auth`` inside the handler — mirroring the pattern
6
+ used by ``unified_ws``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import base64 as _b64
13
+ import logging
14
+ from typing import Any
15
+
16
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
17
+
18
+ from deeptutor.services.config import PROJECT_ROOT, load_config_with_main
19
+ from deeptutor.services.llm import stream as llm_stream
20
+ from deeptutor.services.settings.interface_settings import get_response_language
21
+ from deeptutor.utils.error_utils import format_exception_message
22
+
23
+ logger = logging.getLogger(__name__)
24
+ _config = load_config_with_main("main.yaml", PROJECT_ROOT)
25
+
26
+ router = APIRouter()
27
+
28
+
29
+ _JUDGE_SYSTEM_PROMPTS = {
30
+ "zh": (
31
+ "你是一名严谨且鼓励学习者的助教,正在批改一道测验题。"
32
+ "请基于题目、参考答案与解析,对学习者的作答给出针对性的判定与反馈。\n\n"
33
+ "回答要求:\n"
34
+ "- 先用一行明确结论:✅ 正确 / ⚠️ 部分正确 / ❌ 不正确,并简短点明关键判定依据。\n"
35
+ "- 然后分条列出:哪里做对了、哪里出错或缺漏、应该如何改正。\n"
36
+ "- 若题目本身有多种合理答案,请承认学习者的合理之处。\n"
37
+ "- 直接以学习者的作答为对象,不要泛泛而谈。\n"
38
+ "- 全程使用中文。"
39
+ ),
40
+ "en": (
41
+ "You are a rigorous yet encouraging teaching assistant grading a learner's quiz answer. "
42
+ "Use the question, reference answer, and explanation to deliver a targeted assessment.\n\n"
43
+ "Requirements:\n"
44
+ "- Open with one line that states the verdict: ✅ Correct / ⚠️ Partially correct / ❌ Incorrect, "
45
+ "and the key reason.\n"
46
+ "- Then list: what the learner got right, what is wrong or missing, and how to fix it.\n"
47
+ "- If multiple reasonable answers exist, acknowledge what the learner did well.\n"
48
+ "- Speak directly to the learner's submission — do not give a generic lecture.\n"
49
+ "- Reply in English."
50
+ ),
51
+ "de": (
52
+ "Du bist eine gründliche, aber ermutigende Lehrkraft und bewertest die Antwort "
53
+ "einer lernenden Person auf eine Quizfrage. Stütze dich auf Frage, Musterlösung "
54
+ "und Erklärung.\n\n"
55
+ "Anforderungen:\n"
56
+ "- Beginne mit einer Zeile, die das Ergebnis nennt: ✅ Richtig / ⚠️ Teilweise richtig "
57
+ "/ ❌ Falsch, und kurz den entscheidenden Grund.\n"
58
+ "- Liste danach auf: was richtig ist, was falsch ist oder fehlt und wie es sich verbessern lässt.\n"
59
+ "- Wenn mehrere sinnvolle Antworten möglich sind, erkenne an, was gut gelöst ist.\n"
60
+ "- Sprich direkt über diese Antwort, ohne allgemeinen Vortrag.\n"
61
+ "- Antworte auf Deutsch und duze die lernende Person."
62
+ ),
63
+ "uk": (
64
+ "Ти вимогливий, але доброзичливий асистент учителя, який перевіряє відповідь учня "
65
+ "на тестове завдання. Спирайся на умову, еталонну відповідь і пояснення.\n\n"
66
+ "Вимоги до відповіді:\n"
67
+ "- Почни одним рядком із вердиктом: ✅ Правильно / ⚠️ Частково правильно / ❌ Неправильно "
68
+ "і коротко назви головну підставу.\n"
69
+ "- Далі по пунктах: що учень зробив правильно, де помилка або чого бракує, як це виправити.\n"
70
+ "- Якщо можливі кілька слушних відповідей, визнай те, що учень зробив добре.\n"
71
+ "- Звертайся саме до цієї відповіді, без загальних лекцій.\n"
72
+ "- Відповідай українською."
73
+ ),
74
+ }
75
+
76
+ # The set of languages the judge can speak IS the set of system prompts it has.
77
+ # Deriving it here means adding a language is one edit (add a prompt), not three
78
+ # — the previous ("zh", "en") literals were repeated in the whitelist, the
79
+ # fallback and the frontend, and a Ukrainian UI silently got English feedback.
80
+ SUPPORTED_JUDGE_LANGUAGES: frozenset[str] = frozenset(_JUDGE_SYSTEM_PROMPTS)
81
+
82
+
83
+ def _build_judge_user_prompt(
84
+ *,
85
+ language: str,
86
+ question: str,
87
+ question_type: str,
88
+ options: dict | None,
89
+ correct_answer: str,
90
+ explanation: str,
91
+ user_answer: str,
92
+ has_image: bool,
93
+ image_count: int = 0,
94
+ ) -> str:
95
+ options_block = ""
96
+ if options:
97
+ try:
98
+ options_block = "\n".join(f" {k}. {v}" for k, v in options.items())
99
+ except Exception:
100
+ options_block = ""
101
+ if language == "zh":
102
+ parts = [
103
+ f"题目类型:{question_type or 'unknown'}",
104
+ f"题干:\n{question}",
105
+ ]
106
+ if options_block:
107
+ parts.append(f"选项:\n{options_block}")
108
+ if correct_answer:
109
+ parts.append(f"参考答案:\n{correct_answer}")
110
+ if explanation:
111
+ parts.append(f"参考解析:\n{explanation}")
112
+ parts.append(
113
+ "学习者作答:\n"
114
+ + (
115
+ user_answer.strip()
116
+ if user_answer and user_answer.strip()
117
+ else "(仅提交了图片,无文字作答)"
118
+ )
119
+ )
120
+ if has_image:
121
+ count_text = (
122
+ f"学习者另附了 {image_count} 张图片作为作答内容"
123
+ if image_count > 1
124
+ else "学习者另附了一张图片作为作答内容"
125
+ )
126
+ parts.append(f"{count_text},请结合图片中的文字/公式/草图一并判定。")
127
+ parts.append("请针对该学习者的具体作答给出 AI 评判。")
128
+ else:
129
+ parts = [
130
+ f"Question type: {question_type or 'unknown'}",
131
+ f"Question:\n{question}",
132
+ ]
133
+ if options_block:
134
+ parts.append(f"Options:\n{options_block}")
135
+ if correct_answer:
136
+ parts.append(f"Reference answer:\n{correct_answer}")
137
+ if explanation:
138
+ parts.append(f"Reference explanation:\n{explanation}")
139
+ parts.append(
140
+ "Learner's answer:\n"
141
+ + (
142
+ user_answer.strip()
143
+ if user_answer and user_answer.strip()
144
+ else "(only an image was submitted, no typed answer)"
145
+ )
146
+ )
147
+ if has_image:
148
+ if image_count > 1:
149
+ parts.append(
150
+ f"The learner attached {image_count} images as part of the answer. "
151
+ "Read their text/formulas/sketches and factor them into the judgment."
152
+ )
153
+ else:
154
+ parts.append(
155
+ "The learner attached an image as part of the answer. "
156
+ "Read its text/formulas/sketches and factor it into the judgment."
157
+ )
158
+ parts.append("Produce an AI judgment that addresses this learner's specific answer.")
159
+ return "\n\n".join(parts)
160
+
161
+
162
+ async def _build_multimodal_user_content(
163
+ *,
164
+ text: str,
165
+ image_records: list[dict[str, str]],
166
+ ) -> list[dict[str, Any]]:
167
+ """Compose an OpenAI-style content-parts array with text + image blocks.
168
+
169
+ For ``url``-only records we resolve local AttachmentStore paths to
170
+ base64 here (most providers can fetch external URLs themselves, but
171
+ locally-hosted ``/files/attachments/...`` is only reachable from the
172
+ browser). Falls back to passing the URL through when resolution is
173
+ not possible.
174
+ """
175
+ from urllib.parse import unquote, urlparse
176
+
177
+ from deeptutor.services.storage import get_attachment_store
178
+
179
+ content: list[dict[str, Any]] = [{"type": "text", "text": text}]
180
+ attachment_store = get_attachment_store()
181
+ resolve = getattr(attachment_store, "resolve_path", None)
182
+
183
+ for record in image_records:
184
+ b64 = record.get("base64") or ""
185
+ url = record.get("url") or ""
186
+ filename = record.get("filename") or "answer.png"
187
+ mime_type = record.get("mime_type") or _guess_image_mime(filename)
188
+
189
+ if not b64 and url and resolve is not None:
190
+ try:
191
+ parsed = urlparse(url)
192
+ parts = (parsed.path or url).strip("/").split("/")
193
+ # Current store URLs use files/attachments; keep older saved
194
+ # api/attachments references readable as well.
195
+ if len(parts) >= 5 and parts[:2] in (
196
+ ["files", "attachments"],
197
+ ["api", "attachments"],
198
+ ):
199
+ sid = unquote(parts[2])
200
+ aid = unquote(parts[3])
201
+ name = unquote("/".join(parts[4:]))
202
+ target = resolve(session_id=sid, attachment_id=aid, filename=name)
203
+ if target is not None and target.exists():
204
+ b64 = _b64.b64encode(target.read_bytes()).decode("ascii")
205
+ except Exception as exc:
206
+ logger.debug("Could not resolve %s to bytes: %s", url, exc)
207
+
208
+ if b64:
209
+ data_url = f"data:{mime_type};base64,{b64}"
210
+ content.append({"type": "image_url", "image_url": {"url": data_url}})
211
+ elif url:
212
+ content.append({"type": "image_url", "image_url": {"url": url}})
213
+
214
+ return content
215
+
216
+
217
+ def _guess_image_mime(filename: str | None) -> str:
218
+ if not filename:
219
+ return "image/png"
220
+ ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
221
+ return {
222
+ "png": "image/png",
223
+ "jpg": "image/jpeg",
224
+ "jpeg": "image/jpeg",
225
+ "gif": "image/gif",
226
+ "webp": "image/webp",
227
+ }.get(ext, "image/png")
228
+
229
+
230
+ @router.websocket("/questions/judge")
231
+ async def websocket_quiz_judge(websocket: WebSocket):
232
+ """Stream an AI judgment for a single quiz answer.
233
+
234
+ Auth is enforced via ``ws_require_auth`` rather than a router-level
235
+ HTTP dependency — see module docstring.
236
+
237
+ Client → Server (initial JSON):
238
+ {
239
+ "question": str,
240
+ "question_type": str,
241
+ "options": dict | null,
242
+ "correct_answer": str,
243
+ "explanation": str,
244
+ "user_answer": str,
245
+ # New: list of image entries. Each entry has either ``base64``
246
+ # (no ``data:`` prefix) or ``url`` (already hosted via the
247
+ # AttachmentStore). ``user_answer_image`` (single, base64) is
248
+ # still accepted for backward compatibility.
249
+ "user_answer_images": [
250
+ {"base64": str, "url": str, "filename": str, "mime_type": str},
251
+ ...
252
+ ] | null,
253
+ "user_answer_image": str | null, # legacy single-image form
254
+ "image_filename": str | null, # legacy filename for the above
255
+ "language": "zh" | "en" | "de" | "uk",
256
+ }
257
+
258
+ Server → Client (streaming):
259
+ {"type": "started"}
260
+ {"type": "text", "content": "..."} # zero or more
261
+ {"type": "done"}
262
+ {"type": "error", "content": "..."}
263
+ """
264
+ from deeptutor.api.routers.auth import ws_auth_failed, ws_require_auth
265
+ from deeptutor.multi_user.context import reset_current_user
266
+
267
+ user_token = await ws_require_auth(websocket)
268
+ if user_token is ws_auth_failed:
269
+ return
270
+
271
+ await websocket.accept()
272
+
273
+ async def safe_send(payload: dict[str, Any]) -> bool:
274
+ try:
275
+ await websocket.send_json(payload)
276
+ return True
277
+ except (WebSocketDisconnect, RuntimeError, ConnectionError):
278
+ return False
279
+
280
+ try:
281
+ data = await websocket.receive_json()
282
+ except WebSocketDisconnect:
283
+ return
284
+ except Exception as exc:
285
+ await safe_send({"type": "error", "content": f"Invalid request: {exc}"})
286
+ try:
287
+ await websocket.close()
288
+ except Exception:
289
+ pass
290
+ if user_token is not None:
291
+ try:
292
+ reset_current_user(user_token)
293
+ except Exception:
294
+ pass
295
+ return
296
+
297
+ question_text = (data.get("question") or "").strip()
298
+ if not question_text:
299
+ await safe_send({"type": "error", "content": "Question is required"})
300
+ try:
301
+ await websocket.close()
302
+ except Exception:
303
+ pass
304
+ if user_token is not None:
305
+ try:
306
+ reset_current_user(user_token)
307
+ except Exception:
308
+ pass
309
+ return
310
+
311
+ requested_language = (data.get("language") or "").strip().lower()
312
+ if requested_language not in SUPPORTED_JUDGE_LANGUAGES:
313
+ requested_language = get_response_language(
314
+ default=_config.get("system", {}).get("language", "en")
315
+ )
316
+ if requested_language not in SUPPORTED_JUDGE_LANGUAGES:
317
+ requested_language = "en"
318
+
319
+ user_answer = data.get("user_answer") or ""
320
+
321
+ # Resolve the image set. New clients send ``user_answer_images`` (list);
322
+ # legacy clients send the single ``user_answer_image`` + ``image_filename``
323
+ # pair. Build a uniform list of ``{base64, url, filename, mime_type}`` so
324
+ # the downstream multimodal-message builder doesn't care which form
325
+ # arrived.
326
+ raw_images = data.get("user_answer_images")
327
+ image_records: list[dict[str, str]] = []
328
+ if isinstance(raw_images, list):
329
+ for entry in raw_images:
330
+ if not isinstance(entry, dict):
331
+ continue
332
+ b64 = entry.get("base64") or ""
333
+ url = entry.get("url") or ""
334
+ if isinstance(b64, str) and b64.startswith("data:"):
335
+ try:
336
+ b64 = b64.split(",", 1)[1]
337
+ except IndexError:
338
+ b64 = ""
339
+ if not b64 and not url:
340
+ continue
341
+ filename = entry.get("filename") or "answer.png"
342
+ mime_type = entry.get("mime_type") or _guess_image_mime(filename)
343
+ image_records.append(
344
+ {
345
+ "base64": b64,
346
+ "url": url,
347
+ "filename": filename,
348
+ "mime_type": mime_type,
349
+ }
350
+ )
351
+ else:
352
+ legacy_b64 = data.get("user_answer_image") or ""
353
+ if isinstance(legacy_b64, str) and legacy_b64.startswith("data:"):
354
+ try:
355
+ legacy_b64 = legacy_b64.split(",", 1)[1]
356
+ except IndexError:
357
+ legacy_b64 = ""
358
+ if legacy_b64:
359
+ legacy_filename = data.get("image_filename") or "answer.png"
360
+ image_records.append(
361
+ {
362
+ "base64": legacy_b64,
363
+ "url": "",
364
+ "filename": legacy_filename,
365
+ "mime_type": _guess_image_mime(legacy_filename),
366
+ }
367
+ )
368
+
369
+ has_image = bool(image_records)
370
+
371
+ options_value = data.get("options") if isinstance(data.get("options"), dict) else None
372
+ system_prompt = _JUDGE_SYSTEM_PROMPTS.get(requested_language, _JUDGE_SYSTEM_PROMPTS["en"])
373
+ user_prompt = _build_judge_user_prompt(
374
+ language=requested_language,
375
+ question=question_text,
376
+ question_type=data.get("question_type") or "",
377
+ options=options_value,
378
+ correct_answer=data.get("correct_answer") or "",
379
+ explanation=data.get("explanation") or "",
380
+ user_answer=user_answer,
381
+ has_image=has_image,
382
+ image_count=len(image_records),
383
+ )
384
+
385
+ if not (user_answer.strip() or has_image):
386
+ await safe_send(
387
+ {
388
+ "type": "error",
389
+ "content": ("No answer to judge — submit a typed answer or attach an image."),
390
+ }
391
+ )
392
+ try:
393
+ await websocket.close()
394
+ except Exception:
395
+ pass
396
+ if user_token is not None:
397
+ try:
398
+ reset_current_user(user_token)
399
+ except Exception:
400
+ pass
401
+ return
402
+
403
+ await safe_send({"type": "started"})
404
+
405
+ # Build a multimodal user message when ≥1 image was attached. We pass
406
+ # the full ``messages`` array to ``factory.stream`` so it forwards the
407
+ # content-parts unchanged (the single-image ``image_data`` kwarg only
408
+ # supports one image).
409
+ stream_kwargs: dict[str, Any] = {}
410
+ if has_image:
411
+ from deeptutor.services.llm import config as _llm_config_mod
412
+ from deeptutor.services.llm.capabilities import supports_vision
413
+
414
+ llm_cfg = _llm_config_mod.get_llm_config()
415
+ binding = getattr(llm_cfg, "binding", "openai") or "openai"
416
+ model = getattr(llm_cfg, "model", "") or ""
417
+ if supports_vision(binding, model):
418
+ user_content = await _build_multimodal_user_content(
419
+ text=user_prompt,
420
+ image_records=image_records,
421
+ )
422
+ stream_kwargs["messages"] = [
423
+ {"role": "system", "content": system_prompt},
424
+ {"role": "user", "content": user_content},
425
+ ]
426
+ else:
427
+ # Vision-incapable model — fall back to text-only judge so the
428
+ # learner still gets feedback on their typed answer.
429
+ logger.info(
430
+ "Judge: %s/%s does not support vision; dropping %d image(s)",
431
+ binding,
432
+ model,
433
+ len(image_records),
434
+ )
435
+
436
+ try:
437
+ async with asyncio.timeout(2 * 60):
438
+ async for chunk in llm_stream(
439
+ prompt=user_prompt,
440
+ system_prompt=system_prompt,
441
+ **stream_kwargs,
442
+ ):
443
+ if not chunk:
444
+ continue
445
+ if not await safe_send({"type": "text", "content": chunk}):
446
+ break
447
+ await safe_send({"type": "done"})
448
+ except TimeoutError:
449
+ await safe_send({"type": "error", "content": "AI judge timed out. Please try again."})
450
+ except WebSocketDisconnect:
451
+ logger.debug("AI judge client disconnected mid-stream")
452
+ except Exception as exc:
453
+ logger.exception("AI judge stream failed")
454
+ await safe_send({"type": "error", "content": format_exception_message(exc)})
455
+ finally:
456
+ try:
457
+ await websocket.close()
458
+ except Exception:
459
+ pass
460
+ if user_token is not None:
461
+ try:
462
+ reset_current_user(user_token)
463
+ except Exception:
464
+ pass