deeptutor-questions 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.
@@ -0,0 +1,33 @@
1
+ """Question generation package.
2
+
3
+ The main entry point is :class:`~deeptutor.agents.question.pipeline.QuestionPipeline`.
4
+ Lightweight names (``FollowupAgent``, ``QuizTemplate``, ``QuizPair``, etc.)
5
+ are resolved lazily so callers that only need one symbol don't eagerly
6
+ import the full pipeline + its LLM dependencies.
7
+ """
8
+
9
+ from importlib import import_module
10
+ from typing import Any
11
+
12
+ __all__ = [
13
+ "AgentCoordinator",
14
+ "FollowupAgent",
15
+ "QuestionPipeline",
16
+ "QuizTemplate",
17
+ "QuizPair",
18
+ "QuizPlan",
19
+ "QuizHistoryEntry",
20
+ ]
21
+
22
+
23
+ def __getattr__(name: str) -> Any:
24
+ if name == "AgentCoordinator":
25
+ module = import_module("deeptutor.agents.question.coordinator")
26
+ return getattr(module, name)
27
+ if name == "FollowupAgent":
28
+ module = import_module("deeptutor.agents.question.agents.followup_agent")
29
+ return getattr(module, name)
30
+ if name in {"QuestionPipeline", "QuizTemplate", "QuizPair", "QuizPlan", "QuizHistoryEntry"}:
31
+ module = import_module("deeptutor.agents.question.pipeline")
32
+ return getattr(module, name)
33
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,19 @@
1
+ """Question generation sub-agents.
2
+
3
+ Currently only the standalone single-call ``FollowupAgent`` lives here —
4
+ the per-question / per-batch agents (idea_agent, generator) were
5
+ replaced by the single :mod:`deeptutor.agents.question.pipeline` module
6
+ during the Phase A → C refactor.
7
+ """
8
+
9
+ from importlib import import_module
10
+ from typing import Any
11
+
12
+ __all__ = ["FollowupAgent"]
13
+
14
+
15
+ def __getattr__(name: str) -> Any:
16
+ if name == "FollowupAgent":
17
+ module = import_module("deeptutor.agents.question.agents.followup_agent")
18
+ return getattr(module, name)
19
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Single-call follow-up agent for quiz question threads.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from deeptutor.agents.base_agent import BaseAgent
11
+ from deeptutor.core.trace import build_trace_metadata, new_call_id
12
+ from deeptutor.services.prompt.language import append_language_directive
13
+
14
+
15
+ class FollowupAgent(BaseAgent):
16
+ """Answer follow-up questions about a single quiz item in one LLM call."""
17
+
18
+ def __init__(self, language: str = "en", **kwargs: Any) -> None:
19
+ super().__init__(
20
+ module_name="question",
21
+ agent_name="followup_agent",
22
+ language=language,
23
+ **kwargs,
24
+ )
25
+
26
+ async def process(
27
+ self,
28
+ *,
29
+ user_message: str,
30
+ question_context: dict[str, Any],
31
+ history_context: str = "",
32
+ attachments: list[Any] | None = None,
33
+ ) -> str:
34
+ system_prompt = append_language_directive(
35
+ self.get_prompt("system", ""),
36
+ self.language,
37
+ )
38
+ user_prompt_template = self.get_prompt("answer_followup", "")
39
+ if not user_prompt_template:
40
+ user_prompt_template = (
41
+ "Question context:\n{question_context}\n\n"
42
+ "Conversation history:\n{history_context}\n\n"
43
+ "User follow-up:\n{user_message}\n"
44
+ )
45
+
46
+ user_prompt = user_prompt_template.format(
47
+ question_context=self._render_question_context(question_context),
48
+ history_context=history_context or "(none)",
49
+ user_message=user_message.strip() or "(empty)",
50
+ )
51
+
52
+ _chunks: list[str] = []
53
+ async for _c in self.stream_llm(
54
+ user_prompt=user_prompt,
55
+ system_prompt=system_prompt,
56
+ stage="followup_answer",
57
+ attachments=attachments,
58
+ trace_meta=build_trace_metadata(
59
+ call_id=new_call_id(
60
+ f"quiz-followup-{question_context.get('question_id', 'question')}"
61
+ ),
62
+ phase="generation",
63
+ label=f"Answer follow-up for {self._humanize_question_id(question_context.get('question_id', 'question'))}",
64
+ call_kind="llm_generation",
65
+ trace_id=str(question_context.get("question_id", "question")),
66
+ question_id=str(question_context.get("question_id", "")),
67
+ ),
68
+ ):
69
+ _chunks.append(_c)
70
+ return "".join(_chunks)
71
+
72
+ @staticmethod
73
+ def _humanize_question_id(question_id: Any) -> str:
74
+ raw = str(question_id or "").strip()
75
+ if raw.lower().startswith("q_") and raw[2:].isdigit():
76
+ return f"Question {raw[2:]}"
77
+ return raw or "question"
78
+
79
+ @staticmethod
80
+ def _render_question_context(question_context: dict[str, Any]) -> str:
81
+ options = question_context.get("options") or {}
82
+ option_lines: list[str] = []
83
+ if isinstance(options, dict):
84
+ for key, value in options.items():
85
+ if str(value or "").strip():
86
+ option_lines.append(f"{key}. {value}")
87
+
88
+ correctness = question_context.get("is_correct")
89
+ correctness_text = (
90
+ "correct" if correctness is True else "incorrect" if correctness is False else "unknown"
91
+ )
92
+
93
+ lines = [
94
+ f"Question ID: {question_context.get('question_id') or '(none)'}",
95
+ f"Question type: {question_context.get('question_type') or '(none)'}",
96
+ f"Difficulty: {question_context.get('difficulty') or '(none)'}",
97
+ f"Concentration: {question_context.get('concentration') or '(none)'}",
98
+ "",
99
+ "Question:",
100
+ str(question_context.get("question", "") or "(none)"),
101
+ ]
102
+ if option_lines:
103
+ lines.extend(["", "Options:", *option_lines])
104
+ lines.extend(
105
+ [
106
+ "",
107
+ f"Learner answer: {question_context.get('user_answer') or '(not provided)'}",
108
+ f"Learner result: {correctness_text}",
109
+ f"Reference answer: {question_context.get('correct_answer') or '(none)'}",
110
+ "",
111
+ "Explanation:",
112
+ str(question_context.get("explanation", "") or "(none)"),
113
+ ]
114
+ )
115
+ knowledge_context = str(question_context.get("knowledge_context", "") or "").strip()
116
+ if knowledge_context:
117
+ lines.extend(["", "Knowledge context:", knowledge_context])
118
+ return "\n".join(lines).strip()
@@ -0,0 +1,465 @@
1
+ """Deep Question Capability.
2
+
3
+ Routes one user turn through the right quiz-generation path:
4
+
5
+ * followup — single-call ``FollowupAgent`` reply about one prior question.
6
+ * custom mode — new ``QuestionPipeline`` (explore → plan → per-question loop).
7
+ * mimic mode — same pipeline, but PDF parsing produces the templates
8
+ and ``templates_override`` skips explore + plan.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import base64
15
+ from pathlib import Path
16
+ import tempfile
17
+ from typing import Any
18
+
19
+ from deeptutor.agents._shared.capability_result import emit_capability_result
20
+ from deeptutor.core.capability_protocol import TurnCapability
21
+ from deeptutor.core.context import UnifiedContext
22
+ from deeptutor.core.trace import merge_trace_metadata
23
+ from deeptutor.i18n import StatusI18n
24
+ from deeptutor.runtime.agentic.usage import UsageTracker
25
+ from deeptutor.runtime.bootstrap.builtin_capabilities import builtin_capability_manifest
26
+ from deeptutor.runtime.stream_bus import StreamBus
27
+
28
+
29
+ class DeepQuestionCapability(TurnCapability):
30
+ manifest = builtin_capability_manifest("deep_question")
31
+
32
+ async def run(self, context: UnifiedContext, stream: StreamBus) -> None:
33
+ from deeptutor.services.llm.config import get_llm_config
34
+
35
+ llm_config = get_llm_config()
36
+ kb_name = context.knowledge_bases[0] if context.knowledge_bases else None
37
+ turn_id = str(context.metadata.get("turn_id", "") or context.session_id or "deep-question")
38
+ i18n = StatusI18n(self.name, context.language, module="question")
39
+
40
+ overrides = context.config_overrides
41
+ mode = str(overrides.get("mode", "custom") or "custom").strip().lower()
42
+ topic = str(overrides.get("topic") or context.user_message or "").strip()
43
+ # Validate user-fixable input before allocating a workspace or invoking
44
+ # any provider/parser. This keeps an empty custom request from
45
+ # surfacing a low-level filesystem error such as ``Operation not
46
+ # permitted``.
47
+ if mode == "custom" and not topic:
48
+ await stream.error(
49
+ i18n.t(
50
+ "topic_required",
51
+ "Please provide a topic before generating questions.",
52
+ ),
53
+ source=self.name,
54
+ metadata={"code": "topic_required", "retryable": True},
55
+ )
56
+ return
57
+ runtime_workspace = context.runtime.workspace
58
+ if runtime_workspace is not None:
59
+ output_dir = Path(runtime_workspace.output_dir) / "question_generation"
60
+ output_dir.mkdir(parents=True, exist_ok=True)
61
+ else:
62
+ # Direct legacy callers that do not construct a TurnRuntimeContext
63
+ # retain their historical storage path.
64
+ from deeptutor.services.path_service import get_path_service
65
+
66
+ output_dir = get_path_service().get_task_workspace("deep_question", turn_id)
67
+ followup_question_context = context.metadata.get("question_followup_context", {}) or {}
68
+ if isinstance(followup_question_context, dict) and followup_question_context.get(
69
+ "question"
70
+ ):
71
+ from deeptutor.agents.question.agents.followup_agent import FollowupAgent
72
+
73
+ usage = UsageTracker(model=getattr(llm_config, "model", None))
74
+ agent = FollowupAgent(
75
+ language=context.language,
76
+ api_key=llm_config.api_key,
77
+ base_url=llm_config.base_url,
78
+ api_version=llm_config.api_version,
79
+ token_tracker=usage,
80
+ )
81
+ agent.set_trace_callback(self._build_trace_bridge(stream, i18n=i18n))
82
+ async with stream.stage("generation", source=self.name):
83
+ answer = await agent.process(
84
+ user_message=context.user_message,
85
+ question_context=followup_question_context,
86
+ history_context=str(
87
+ context.metadata.get("conversation_context_text", "") or ""
88
+ ).strip(),
89
+ attachments=context.attachments,
90
+ )
91
+ if answer:
92
+ await stream.content(answer, source=self.name, stage="generation")
93
+ followup_payload: dict[str, Any] = {
94
+ "response": answer or "",
95
+ "mode": "followup",
96
+ "question_id": followup_question_context.get("question_id", ""),
97
+ }
98
+ await emit_capability_result(
99
+ stream, followup_payload, source=self.name, usage=usage
100
+ )
101
+ return
102
+
103
+ mode = str(overrides.get("mode", "custom") or "custom").strip().lower()
104
+ topic = str(overrides.get("topic") or context.user_message or "").strip()
105
+ num_questions = int(overrides.get("num_questions", 1) or 1)
106
+ difficulty = str(overrides.get("difficulty", "") or "")
107
+ raw_types = overrides.get("question_types") or []
108
+ question_types = list(raw_types) if isinstance(raw_types, list) else []
109
+ raw_counts = overrides.get("per_type_counts") or {}
110
+ per_type_counts = (
111
+ {str(k): int(v) for k, v in raw_counts.items() if isinstance(v, int) and v > 0}
112
+ if isinstance(raw_counts, dict)
113
+ else {}
114
+ )
115
+ history_context = str(context.metadata.get("conversation_context_text", "") or "").strip()
116
+
117
+ if mode != "mimic":
118
+ # New custom-mode pipeline: explore → plan → per-question quiz loop.
119
+ # The pipeline owns its own stream.content / stream.result emission;
120
+ # nothing here to render afterwards.
121
+ from deeptutor.agents.question.history import load_session_quiz_history
122
+ from deeptutor.agents.question.pipeline import QuestionPipeline
123
+ from deeptutor.agents.question.request_config import (
124
+ build_question_runtime_config,
125
+ )
126
+ from deeptutor.services.config import load_config_with_main
127
+
128
+ if not topic:
129
+ await stream.error(
130
+ i18n.t(
131
+ "topic_required",
132
+ "Topic is required for custom question generation.",
133
+ ),
134
+ source=self.name,
135
+ )
136
+ return
137
+
138
+ quiz_history = await load_session_quiz_history(context.session_id or "")
139
+ runtime_config = build_question_runtime_config(
140
+ base_config=load_config_with_main("main.yaml"),
141
+ )
142
+ pipeline = QuestionPipeline(
143
+ language=context.language,
144
+ kb_name=kb_name,
145
+ enabled_tools=list(context.enabled_tools or []),
146
+ runtime_config=runtime_config,
147
+ )
148
+ await pipeline.run(
149
+ context=context,
150
+ user_message=topic,
151
+ num_questions=num_questions,
152
+ difficulty=difficulty,
153
+ question_types=question_types,
154
+ per_type_counts=per_type_counts,
155
+ conversation_context=history_context,
156
+ attachments=context.attachments,
157
+ quiz_history=quiz_history,
158
+ stream=stream,
159
+ )
160
+ return
161
+
162
+ # Mimic mode — also runs through QuestionPipeline, but parses the
163
+ # exam paper into templates first and passes them via
164
+ # ``templates_override`` so explore + plan are skipped.
165
+ await self._run_mimic_mode(
166
+ context=context,
167
+ stream=stream,
168
+ kb_name=kb_name,
169
+ output_dir=output_dir,
170
+ overrides=overrides,
171
+ history_context=history_context,
172
+ num_questions=num_questions,
173
+ i18n=i18n,
174
+ )
175
+
176
+ async def _run_mimic_mode(
177
+ self,
178
+ *,
179
+ context: UnifiedContext,
180
+ stream: StreamBus,
181
+ kb_name: str | None,
182
+ output_dir,
183
+ overrides: dict[str, Any],
184
+ history_context: str,
185
+ num_questions: int,
186
+ i18n: StatusI18n | None = None,
187
+ ) -> None:
188
+ """Resolve an exam paper → templates → ``QuestionPipeline.run`` with
189
+ ``templates_override``. No legacy AgentCoordinator involvement.
190
+
191
+ Three input shapes:
192
+
193
+ * Uploaded PDF attachment → write to tmpfile, parse with MinerU
194
+ * Server-side parsed directory → skip parsing, just extract questions
195
+ * ``[Attached Documents]`` in → no paper available; fall back to
196
+ the user_message text custom-mode pipeline with a
197
+ "mimic the attached source" hint
198
+ prefixed onto the user_message
199
+ """
200
+ from deeptutor.agents.question.history import load_session_quiz_history
201
+ from deeptutor.agents.question.mimic_source import (
202
+ parse_exam_paper_to_templates,
203
+ )
204
+ from deeptutor.agents.question.pipeline import QuestionPipeline
205
+ from deeptutor.agents.question.request_config import (
206
+ build_question_runtime_config,
207
+ )
208
+ from deeptutor.services.config import load_config_with_main
209
+ from deeptutor.services.parsing.engines.mineru.config import MinerUError
210
+
211
+ if i18n is None:
212
+ i18n = StatusI18n(self.name, context.language, module="question")
213
+ paper_path = str(overrides.get("paper_path", "") or "").strip()
214
+ max_questions = int(overrides.get("max_questions", 10) or 10)
215
+ pdf_attachment = next(
216
+ (
217
+ attachment
218
+ for attachment in context.attachments
219
+ if attachment.filename.lower().endswith(".pdf")
220
+ or attachment.type == "pdf"
221
+ or attachment.mime_type == "application/pdf"
222
+ ),
223
+ None,
224
+ )
225
+
226
+ runtime_config = build_question_runtime_config(
227
+ base_config=load_config_with_main("main.yaml"),
228
+ )
229
+ pipeline = QuestionPipeline(
230
+ language=context.language,
231
+ kb_name=kb_name,
232
+ enabled_tools=list(context.enabled_tools or []),
233
+ runtime_config=runtime_config,
234
+ )
235
+ quiz_history = await load_session_quiz_history(context.session_id or "")
236
+
237
+ async def _emit_parse_notice(message: str) -> None:
238
+ async with stream.stage("exploring", source=self.name):
239
+ await stream.thinking(message, source=self.name, stage="exploring")
240
+
241
+ if pdf_attachment and pdf_attachment.base64:
242
+ # Bridge MinerU's progress lines (emitted from the parser worker
243
+ # thread) back onto the event loop so the trace panel streams them
244
+ # live — model downloads and per-page parsing would otherwise look
245
+ # like a silent multi-minute hang.
246
+ loop = asyncio.get_running_loop()
247
+
248
+ def _parse_progress(line: str) -> None:
249
+ asyncio.run_coroutine_threadsafe(
250
+ stream.thinking(line, source=self.name, stage="exploring"),
251
+ loop,
252
+ )
253
+
254
+ try:
255
+ async with stream.stage("exploring", source=self.name):
256
+ await stream.thinking(
257
+ i18n.t(
258
+ "parsing_uploaded",
259
+ "Parsing uploaded exam paper and extracting templates...",
260
+ ),
261
+ source=self.name,
262
+ stage="exploring",
263
+ )
264
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=True) as temp_pdf:
265
+ temp_pdf.write(base64.b64decode(pdf_attachment.base64))
266
+ temp_pdf.flush()
267
+ templates, _ = await parse_exam_paper_to_templates(
268
+ temp_pdf.name,
269
+ max_questions=max_questions,
270
+ paper_mode="upload",
271
+ output_dir=output_dir,
272
+ progress_callback=_parse_progress,
273
+ )
274
+ except MinerUError as exc:
275
+ await stream.error(str(exc), source=self.name)
276
+ return
277
+ await pipeline.run(
278
+ context=context,
279
+ user_message=context.user_message,
280
+ num_questions=len(templates) or num_questions,
281
+ difficulty="",
282
+ conversation_context=history_context,
283
+ attachments=context.attachments,
284
+ quiz_history=quiz_history,
285
+ templates_override=templates,
286
+ stream=stream,
287
+ )
288
+ return
289
+
290
+ if paper_path:
291
+ await _emit_parse_notice(
292
+ i18n.t(
293
+ "parsing_directory",
294
+ "Loading parsed exam paper and extracting templates...",
295
+ )
296
+ )
297
+ try:
298
+ templates, _ = await parse_exam_paper_to_templates(
299
+ paper_path,
300
+ max_questions=max_questions,
301
+ paper_mode="parsed",
302
+ output_dir=output_dir,
303
+ )
304
+ except MinerUError as exc:
305
+ await stream.error(str(exc), source=self.name)
306
+ return
307
+ await pipeline.run(
308
+ context=context,
309
+ user_message=context.user_message,
310
+ num_questions=len(templates) or num_questions,
311
+ difficulty="",
312
+ conversation_context=history_context,
313
+ attachments=context.attachments,
314
+ quiz_history=quiz_history,
315
+ templates_override=templates,
316
+ stream=stream,
317
+ )
318
+ return
319
+
320
+ if "[Attached Documents]" in context.user_message:
321
+ # No paper available — degrade to custom-mode generation but
322
+ # bias the pipeline toward shadowing the attached source by
323
+ # prefixing the user message with an explicit instruction.
324
+ mimic_hint = (
325
+ "[Mimic the attached source document as closely as possible: "
326
+ "style, difficulty, structure, and assessed concepts.]\n\n"
327
+ )
328
+ await pipeline.run(
329
+ context=context,
330
+ user_message=mimic_hint + context.user_message,
331
+ num_questions=max_questions,
332
+ difficulty="",
333
+ conversation_context=history_context,
334
+ attachments=context.attachments,
335
+ quiz_history=quiz_history,
336
+ stream=stream,
337
+ )
338
+ return
339
+
340
+ await stream.error(
341
+ i18n.t(
342
+ "mimic_needs_paper",
343
+ "Mimic mode requires either an uploaded PDF or a parsed exam directory.",
344
+ ),
345
+ source=self.name,
346
+ )
347
+
348
+ def _build_trace_bridge(self, stream: StreamBus, i18n: StatusI18n | None = None):
349
+ async def _trace_bridge(update: dict[str, Any]) -> None:
350
+ event = str(update.get("event", "") or "")
351
+ stage = str(update.get("phase") or update.get("stage") or "generation")
352
+ base_metadata = {
353
+ key: value
354
+ for key, value in update.items()
355
+ if key
356
+ not in {"event", "state", "response", "chunk", "result", "tool_name", "tool_args"}
357
+ }
358
+
359
+ if event == "llm_call":
360
+ state = str(update.get("state", "running"))
361
+ label = str(update.get("label", "") or "")
362
+ if state == "running":
363
+ await stream.progress(
364
+ message=label,
365
+ source=self.name,
366
+ stage=stage,
367
+ metadata=merge_trace_metadata(
368
+ base_metadata,
369
+ {"trace_kind": "call_status", "call_state": "running"},
370
+ ),
371
+ )
372
+ return
373
+ if state == "streaming":
374
+ chunk = str(update.get("chunk", "") or "")
375
+ if chunk:
376
+ await stream.thinking(
377
+ chunk,
378
+ source=self.name,
379
+ stage=stage,
380
+ metadata=merge_trace_metadata(
381
+ base_metadata,
382
+ {"trace_kind": "llm_chunk"},
383
+ ),
384
+ )
385
+ return
386
+ if state == "complete":
387
+ was_streaming = update.get("streaming", False)
388
+ if not was_streaming:
389
+ response = str(update.get("response", "") or "")
390
+ if response:
391
+ await stream.thinking(
392
+ response,
393
+ source=self.name,
394
+ stage=stage,
395
+ metadata=merge_trace_metadata(
396
+ base_metadata,
397
+ {"trace_kind": "llm_output"},
398
+ ),
399
+ )
400
+ await stream.progress(
401
+ message="",
402
+ source=self.name,
403
+ stage=stage,
404
+ metadata=merge_trace_metadata(
405
+ base_metadata,
406
+ {"trace_kind": "call_status", "call_state": "complete"},
407
+ ),
408
+ )
409
+ return
410
+ if state == "error":
411
+ fallback = (
412
+ i18n.t("llm_call_failed", "LLM call failed.")
413
+ if i18n is not None
414
+ else "LLM call failed."
415
+ )
416
+ await stream.error(
417
+ str(update.get("response", "") or fallback),
418
+ source=self.name,
419
+ stage=stage,
420
+ metadata=merge_trace_metadata(
421
+ base_metadata,
422
+ {"trace_kind": "call_status", "call_state": "error"},
423
+ ),
424
+ )
425
+ return
426
+
427
+ if event == "tool_call":
428
+ await stream.tool_call(
429
+ tool_name=str(update.get("tool_name", "") or "tool"),
430
+ args=update.get("tool_args", {}) or {},
431
+ source=self.name,
432
+ stage=stage,
433
+ metadata=merge_trace_metadata(
434
+ base_metadata,
435
+ {"trace_kind": "tool_call"},
436
+ ),
437
+ )
438
+ return
439
+
440
+ if event == "tool_result":
441
+ state = str(update.get("state", "complete"))
442
+ result = str(update.get("result", "") or "")
443
+ if state == "error":
444
+ await stream.error(
445
+ result,
446
+ source=self.name,
447
+ stage=stage,
448
+ metadata=merge_trace_metadata(
449
+ base_metadata,
450
+ {"trace_kind": "tool_result"},
451
+ ),
452
+ )
453
+ return
454
+ await stream.tool_result(
455
+ tool_name=str(update.get("tool_name", "") or "tool"),
456
+ result=result,
457
+ source=self.name,
458
+ stage=stage,
459
+ metadata=merge_trace_metadata(
460
+ base_metadata,
461
+ {"trace_kind": "tool_result"},
462
+ ),
463
+ )
464
+
465
+ return _trace_bridge