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.
- deeptutor/agents/question/__init__.py +33 -0
- deeptutor/agents/question/agents/__init__.py +19 -0
- deeptutor/agents/question/agents/followup_agent.py +118 -0
- deeptutor/agents/question/capability.py +465 -0
- deeptutor/agents/question/coordinator.py +242 -0
- deeptutor/agents/question/history.py +103 -0
- deeptutor/agents/question/mimic_source.py +160 -0
- deeptutor/agents/question/pipeline.py +2315 -0
- deeptutor/agents/question/prompts/en/deep_question.yaml +6 -0
- deeptutor/agents/question/prompts/en/followup_agent.yaml +24 -0
- deeptutor/agents/question/prompts/en/idea_agent.yaml +3 -0
- deeptutor/agents/question/prompts/en/pipeline.yaml +356 -0
- deeptutor/agents/question/prompts/zh/deep_question.yaml +6 -0
- deeptutor/agents/question/prompts/zh/followup_agent.yaml +24 -0
- deeptutor/agents/question/prompts/zh/idea_agent.yaml +3 -0
- deeptutor/agents/question/prompts/zh/pipeline.yaml +336 -0
- deeptutor/agents/question/request_config.py +77 -0
- deeptutor/api/routers/practice.py +288 -0
- deeptutor/api/routers/question.py +580 -0
- deeptutor/api/routers/question_notebook.py +547 -0
- deeptutor/tools/question/__init__.py +38 -0
- deeptutor/tools/question/exam_mimic.py +70 -0
- deeptutor/tools/question/question_extractor.py +413 -0
- deeptutor_questions-1.7.0.dist-info/METADATA +7 -0
- deeptutor_questions-1.7.0.dist-info/RECORD +27 -0
- deeptutor_questions-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_questions-1.7.0.dist-info/top_level.txt +1 -0
|
@@ -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
|