Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

987 lines
36 KiB
Python

"""Unit tests for the new QuestionPipeline primitives.
These tests cover the pure helpers (plan parsing, payload normalization,
issue collection) and the structured per-question emission. End-to-end
flow (loop driving + LLM streaming) is exercised by integration tests
that mock the LLM client; out of scope here.
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any
from unittest.mock import patch
import pytest
from deeptutor.agents.question.pipeline import (
CALL_KIND_QUIZ_QUESTION,
STAGE_QUIZZING,
QuestionPipeline,
QuizPair,
QuizPlan,
QuizTemplate,
)
# ---------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------
def _make_pipeline(language: str = "en") -> QuestionPipeline:
"""Build a pipeline without hitting the network for LLM config."""
# Tests don't drive ``run`` — they only exercise pure helpers and the
# YAML-driven trace metadata builders. So the LLM config can be the
# production one (env-based) without making any actual API calls.
return QuestionPipeline(language=language)
class _StubStreamBus:
"""Captures every emission for assertion. No event ordering checks
beyond ``contents`` containing what we expect."""
def __init__(self) -> None:
# Instance attributes are kept distinct from the method names so the
# methods don't get shadowed when ``progress``/``error`` are called.
# (The original version named both the list and the method
# ``progress``, which silently dropped every captured event.)
self.contents: list[dict[str, Any]] = []
self.progress_events: list[dict[str, Any]] = []
self.error_events: list[dict[str, Any]] = []
async def content(
self,
text: str,
source: str = "",
stage: str = "",
metadata: dict[str, Any] | None = None,
) -> None:
self.contents.append(
{"text": text, "source": source, "stage": stage, "metadata": metadata or {}}
)
async def progress(
self,
message: str,
current: int = 0,
total: int = 0,
source: str = "",
stage: str = "",
metadata: dict[str, Any] | None = None,
) -> None:
self.progress_events.append(
{
"message": message,
"source": source,
"stage": stage,
"metadata": metadata or {},
}
)
async def error(
self,
message: str,
source: str = "",
stage: str = "",
metadata: dict[str, Any] | None = None,
) -> None:
self.error_events.append(
{"message": message, "source": source, "stage": stage, "metadata": metadata or {}}
)
# ---------------------------------------------------------------------------
# parse_plan
# ---------------------------------------------------------------------------
def test_parse_plan_happy_path() -> None:
pipeline = _make_pipeline()
raw = json.dumps(
{
"analysis": "mix of recall + applied",
"templates": [
{"topic": "Definition of X", "question_type": "choice", "difficulty": "easy"},
{"topic": "Apply X to Y", "question_type": "written", "difficulty": "medium"},
],
}
)
plan = pipeline._parse_plan(raw, requested=2, allowed_types=[], target_difficulty="")
assert plan.analysis == "mix of recall + applied"
assert [t.topic for t in plan.templates] == ["Definition of X", "Apply X to Y"]
assert [t.question_id for t in plan.templates] == ["q_1", "q_2"]
assert plan.templates[0].question_type == "choice"
assert plan.templates[1].difficulty == "medium"
def test_parse_plan_dedupes_topics_case_insensitive() -> None:
pipeline = _make_pipeline()
raw = json.dumps(
{
"templates": [
{"topic": "Matrix Multiplication", "question_type": "written"},
{"topic": "matrix multiplication", "question_type": "choice"},
{"topic": "Eigenvalues", "question_type": "written"},
]
}
)
plan = pipeline._parse_plan(raw, requested=3, allowed_types=[], target_difficulty="")
assert len(plan.templates) == 2
assert plan.templates[0].topic == "Matrix Multiplication"
assert plan.templates[1].topic == "Eigenvalues"
def test_parse_plan_respects_user_specified_type_and_difficulty() -> None:
"""When ``allowed_types`` restricts the set to a single type, every
template must use that type. Difficulty override behaves the same way."""
pipeline = _make_pipeline()
raw = json.dumps(
{
"templates": [
{"topic": "T1", "question_type": "choice", "difficulty": "easy"},
{"topic": "T2", "question_type": "written", "difficulty": "hard"},
]
}
)
plan = pipeline._parse_plan(
raw, requested=2, allowed_types=["coding"], target_difficulty="medium"
)
assert all(t.question_type == "coding" for t in plan.templates)
assert all(t.difficulty == "medium" for t in plan.templates)
def test_parse_plan_invalid_json_returns_empty() -> None:
pipeline = _make_pipeline()
plan = pipeline._parse_plan(
"not even json", requested=3, allowed_types=[], target_difficulty=""
)
assert isinstance(plan, QuizPlan)
assert plan.templates == []
def test_parse_plan_truncates_to_requested() -> None:
pipeline = _make_pipeline()
raw = json.dumps(
{
"templates": [
{"topic": f"T{i}", "question_type": "written", "difficulty": "easy"}
for i in range(5)
]
}
)
plan = pipeline._parse_plan(raw, requested=2, allowed_types=[], target_difficulty="")
assert len(plan.templates) == 2
# ---------------------------------------------------------------------------
# Payload normalization + issue collection
# ---------------------------------------------------------------------------
def test_normalize_choice_resolves_answer_text_to_key() -> None:
template = QuizTemplate(question_id="q_1", topic="t", question_type="choice", difficulty="easy")
payload = {
"question": "What?",
"options": {"A": "alpha", "B": "beta", "C": "gamma", "D": "delta"},
"correct_answer": "beta",
"explanation": "because",
}
normalized = QuestionPipeline._normalize_quiz_payload(template, payload)
assert normalized["correct_answer"] == "B"
assert set(normalized["options"].keys()) == {"A", "B", "C", "D"}
def test_collect_issues_choice_missing_keys() -> None:
template = QuizTemplate(question_id="q_1", topic="t", question_type="choice", difficulty="easy")
payload = {
"question": "What?",
"options": {"A": "x", "B": "y", "C": "z"},
"correct_answer": "A",
"explanation": "ok",
}
normalized = QuestionPipeline._normalize_quiz_payload(template, payload)
issues = QuestionPipeline._collect_quiz_issues(template, normalized)
assert "choice_options_must_be_a_to_d" in issues
def test_collect_issues_written_must_not_have_options() -> None:
template = QuizTemplate(
question_id="q_1", topic="t", question_type="written", difficulty="medium"
)
payload = {
"question": "Explain X.",
"options": {"A": "x", "B": "y", "C": "z", "D": "w"},
"correct_answer": "Because…",
"explanation": "ok",
}
normalized = QuestionPipeline._normalize_quiz_payload(template, payload)
# Normalization strips options for non-choice types — so the issue surfaces
# only when the LLM emits a choice-looking shape (single A-D answer key)
# AND options got stripped during normalization. Here options are stripped
# so the structural issue disappears; what remains is the answer-key smell.
assert normalized["options"] is None
issues = QuestionPipeline._collect_quiz_issues(template, normalized)
assert issues == [] # answer text "Because…" is not a single A-D key
def test_collect_issues_written_answer_looks_like_key() -> None:
template = QuizTemplate(
question_id="q_1", topic="t", question_type="written", difficulty="medium"
)
payload = {
"question": "Which is right?",
"correct_answer": "B",
"explanation": "ok",
}
normalized = QuestionPipeline._normalize_quiz_payload(template, payload)
issues = QuestionPipeline._collect_quiz_issues(template, normalized)
assert "non_choice_correct_answer_looks_like_option_key" in issues
def test_collect_issues_missing_fields() -> None:
template = QuizTemplate(
question_id="q_1", topic="t", question_type="written", difficulty="medium"
)
payload: dict[str, Any] = {"question": " ", "correct_answer": "", "explanation": ""}
normalized = QuestionPipeline._normalize_quiz_payload(template, payload)
issues = QuestionPipeline._collect_quiz_issues(template, normalized)
assert {"missing_question", "missing_correct_answer", "missing_explanation"} <= set(issues)
# ---------------------------------------------------------------------------
# _emit_quiz_question — structured event shape
# ---------------------------------------------------------------------------
def test_emit_quiz_question_structures_metadata() -> None:
pipeline = _make_pipeline()
bus = _StubStreamBus()
qa_pair = QuizPair(
question_id="q_2",
question="Solve x.",
question_type="written",
correct_answer="42",
explanation="because",
topic="algebra",
difficulty="easy",
)
asyncio.run(pipeline._emit_quiz_question(stream=bus, qa_pair=qa_pair, index=1, total=3))
assert len(bus.contents) == 1
event = bus.contents[0]
assert event["source"] == "deep_question"
assert event["stage"] == STAGE_QUIZZING
meta = event["metadata"]
assert meta["call_kind"] == CALL_KIND_QUIZ_QUESTION
assert meta["trace_role"] == "quiz_question"
assert meta["question_index"] == 1
assert meta["total_questions"] == 3
# qa_pair is the structured payload the frontend reads to render the card
assert meta["qa_pair"]["question_id"] == "q_2"
assert meta["qa_pair"]["question_type"] == "written"
# ---------------------------------------------------------------------------
# Quiz history loader integration with the sqlite store
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_sqlite_store(tmp_path: Path):
"""Spin up an isolated SQLite session store + force the global getter
to return it for the duration of the test."""
from deeptutor.services.session.sqlite_store import SQLiteSessionStore
store = SQLiteSessionStore(db_path=tmp_path / "session.db")
with patch(
"deeptutor.services.session.sqlite_store.get_sqlite_session_store",
return_value=store,
):
yield store
def test_history_loader_returns_session_scoped_entries(tmp_sqlite_store) -> None:
"""Insert two sessions' worth of quiz answers; loader returns only the
target session's entries, oldest first, with is_correct=None when the
learner never answered."""
from deeptutor.agents.question.history import load_session_quiz_history
store = tmp_sqlite_store
async def setup() -> None:
await store.create_session(session_id="s1", title="quiz session")
await store.create_session(session_id="s2", title="other session")
await store.upsert_notebook_entries(
"s1",
[
{
"turn_id": "t1",
"question_id": "q_1",
"question": "What is 2+2?",
"question_type": "written",
"options": {},
"correct_answer": "4",
"explanation": "addition",
"difficulty": "easy",
"user_answer": "4",
"is_correct": True,
},
{
"turn_id": "t1",
"question_id": "q_2",
"question": "What is 3*3?",
"question_type": "written",
"options": {},
"correct_answer": "9",
"explanation": "multiplication",
"difficulty": "easy",
"user_answer": "8",
"is_correct": False,
},
{
"turn_id": "t2",
"question_id": "q_3",
"question": "What is e^0?",
"question_type": "written",
"options": {},
"correct_answer": "1",
"explanation": "exp",
"difficulty": "medium",
# Unanswered: empty user_answer + is_correct=False (default)
"user_answer": "",
"is_correct": False,
},
],
)
await store.upsert_notebook_entries(
"s2",
[
{
"turn_id": "t99",
"question_id": "q_1",
"question": "OTHER SESSION should not leak",
"question_type": "written",
"correct_answer": "x",
"explanation": "x",
"user_answer": "x",
"is_correct": True,
}
],
)
asyncio.run(setup())
entries = asyncio.run(load_session_quiz_history("s1"))
questions = [e.question for e in entries]
assert "OTHER SESSION should not leak" not in questions
assert questions == ["What is 2+2?", "What is 3*3?", "What is e^0?"] # chronological
assert entries[0].is_correct is True
assert entries[1].is_correct is False
# Unanswered entry: user_answer was empty, so loader surfaces None
# (so the prompt renders "unknown" instead of misleadingly "incorrect").
assert entries[2].is_correct is None
assert entries[2].user_answer == ""
def test_history_loader_returns_empty_for_unknown_session(tmp_sqlite_store) -> None:
from deeptutor.agents.question.history import load_session_quiz_history
entries = asyncio.run(load_session_quiz_history(""))
assert entries == []
entries = asyncio.run(load_session_quiz_history("does-not-exist"))
assert entries == []
# ---------------------------------------------------------------------------
# Tool wiring — regression for the bug where _current_context was None
# at schema-build time, leaving the model with no native tool schemas and
# causing it to improvise fake ``tool_calls`` JSON inside the THINK body.
# ---------------------------------------------------------------------------
def test_tool_schemas_populated_when_kb_attached() -> None:
"""With a KB attached, ``rag`` must be auto-mounted and the tool
schemas the LLM receives must include it with the right ``kb_name``
enum. A regression here means the explore loop runs schema-less and
the model fakes tool calls in text."""
from deeptutor.core.context import UnifiedContext
pipeline = QuestionPipeline(
language="en",
kb_name="demo-kb",
enabled_tools=["web_search"],
)
ctx = UnifiedContext(
user_message="test",
session_id="s1",
metadata={},
enabled_tools=["web_search"],
knowledge_bases=["demo-kb"],
)
resolved = pipeline._resolved_tools(ctx)
assert "rag" in resolved
assert "web_search" in resolved
assert pipeline._use_native_tools(ctx) is True
schemas = pipeline._build_llm_tool_schemas(ctx)
names = [s["function"]["name"] for s in schemas if isinstance(s, dict)]
assert "rag" in names
assert "web_search" in names
rag = next(s for s in schemas if s.get("function", {}).get("name") == "rag")
kb_schema = rag["function"]["parameters"]["properties"].get("kb_name", {})
assert kb_schema.get("enum") == ["demo-kb"], (
"kb_name enum must be populated so the model can't hallucinate"
)
def test_use_native_tools_false_when_no_tools_resolved() -> None:
"""If the registry returns no tools for this turn (rare — e.g. user
has every tool disabled), ``_use_native_tools`` must return False so
we don't pass an empty tools array while the prompt still mentions
tools. Otherwise the model invents calls in text."""
from deeptutor.core.context import UnifiedContext
pipeline = _make_pipeline()
pipeline.kb_name = None
ctx = UnifiedContext(
user_message="test",
session_id="s1",
metadata={},
enabled_tools=[],
knowledge_bases=[],
)
# web_fetch / github / ask_user are always auto-mounted, so we still
# expect _use_native_tools True under the default registry. The point
# of this regression is the *guard*: the function inspects resolved
# tools at all, not just binding/model — so an empty tool list won't
# silently pass through.
if pipeline._resolved_tools(ctx):
assert pipeline._use_native_tools(ctx) is True
else: # pragma: no cover — only reachable in stripped registries
assert pipeline._use_native_tools(ctx) is False
# ---------------------------------------------------------------------------
# Mimic mode — templates_override path
# ---------------------------------------------------------------------------
def test_reference_block_renders_for_mimic_templates() -> None:
"""mimic templates expose their original exam-paper question + answer
so the quiz step can shadow / paraphrase the source. custom templates
get the no-reference placeholder instead, so the model knows to
invent the stem."""
pipeline = _make_pipeline()
custom = QuizTemplate(
question_id="q_1", topic="t", question_type="written", difficulty="medium"
)
block_custom = pipeline._render_reference_block(custom)
assert "no reference" in block_custom.lower() or block_custom.startswith("(")
mimic = QuizTemplate(
question_id="q_1",
topic="t",
question_type="written",
difficulty="medium",
source="mimic",
reference_question="Prove that the eigenvalues of a Hermitian matrix are real.",
reference_answer="Use ⟨Ax, x⟩ = ⟨x, Ax⟩ …",
)
block_mimic = pipeline._render_reference_block(mimic)
assert "Reference question" in block_mimic
assert "Hermitian" in block_mimic
assert "Reference answer" in block_mimic
def test_build_result_payload_mode_mimic() -> None:
"""``is_mimic=True`` flips the envelope's ``mode`` + ``summary.source``
so the frontend / notebook layer can distinguish topic-driven from
exam-driven quizzes. Without this, mimic results were indistinguishable
from custom and the analytics rolled them together."""
pipeline = _make_pipeline()
plan = QuizPlan(
analysis="from-exam",
templates=[
QuizTemplate(
question_id="q_1",
topic="Hermitian eigenvalues",
question_type="written",
difficulty="medium",
source="mimic",
reference_question="ref Q",
reference_answer="ref A",
)
],
)
qa = QuizPair(
question_id="q_1",
question="Prove …",
question_type="written",
correct_answer="…",
explanation="…",
topic="Hermitian eigenvalues",
difficulty="medium",
)
payload_mimic = pipeline._build_result_payload(plan, [qa], is_mimic=True)
assert payload_mimic["mode"] == "mimic"
assert payload_mimic["summary"]["source"] == "exam"
# source / reference fields ride along on the template snapshot for
# downstream consumers that want to render "from exam paper" badges.
assert payload_mimic["summary"]["templates"][0]["source"] == "mimic"
assert payload_mimic["summary"]["templates"][0]["reference_question"] == "ref Q"
payload_custom = pipeline._build_result_payload(plan, [qa], is_mimic=False)
assert payload_custom["mode"] == "custom"
assert payload_custom["summary"]["source"] == "topic"
def test_run_with_templates_override_skips_explore_and_plan(monkeypatch) -> None:
"""``templates_override`` is the mimic-mode hook: when provided, the
pipeline must jump straight to the quiz phase. This guards against a
refactor accidentally re-enabling the explore / plan calls for mimic
(which would burn extra LLM rounds and clobber the planner-fixed
template list with one the LLM invented).
We patch out ``_explore``, ``_plan``, ``_quiz_one``, and ``stream.result``
so we can inspect *which* phases ran without the LLM client touching
the network.
"""
from deeptutor.core.context import UnifiedContext
pipeline = QuestionPipeline(language="en")
ctx = UnifiedContext(
user_message="please quiz me",
session_id="s1",
metadata={},
enabled_tools=[],
knowledge_bases=[],
)
explore_calls: list[None] = []
plan_calls: list[None] = []
quiz_calls: list[QuizTemplate] = []
async def _fake_explore(**kwargs: Any) -> str:
explore_calls.append(None)
return "should not run"
async def _fake_plan(**kwargs: Any) -> QuizPlan:
plan_calls.append(None)
return QuizPlan(analysis="", templates=[])
async def _fake_quiz_one(*, template: QuizTemplate, **kwargs: Any) -> QuizPair:
quiz_calls.append(template)
return QuizPair(
question_id=template.question_id,
question=template.reference_question or template.topic,
question_type=template.question_type,
correct_answer="A",
explanation="…",
topic=template.topic,
difficulty=template.difficulty,
)
async def _fake_emit(**kwargs: Any) -> None:
return None
from contextlib import asynccontextmanager
bus = _StubStreamBus()
@asynccontextmanager
async def _fake_stage(*args: Any, **kwargs: Any):
yield None
async def _fake_result(payload: dict[str, Any], **kwargs: Any) -> None:
bus.contents.append({"text": "result", "metadata": payload})
bus.stage = _fake_stage # type: ignore[attr-defined]
bus.result = _fake_result # type: ignore[attr-defined]
monkeypatch.setattr(pipeline, "_explore", _fake_explore)
monkeypatch.setattr(pipeline, "_plan", _fake_plan)
monkeypatch.setattr(pipeline, "_quiz_one", _fake_quiz_one)
monkeypatch.setattr(pipeline, "_emit_quiz_question", _fake_emit)
# build_openai_client tries to materialise a real client; stub it.
monkeypatch.setattr(
"deeptutor.agents.question.pipeline.build_openai_client",
lambda config: object(),
)
templates = [
QuizTemplate(
question_id="q_1",
topic="ref topic 1",
question_type="written",
difficulty="medium",
source="mimic",
reference_question="Q1?",
reference_answer="A1",
),
QuizTemplate(
question_id="q_2",
topic="ref topic 2",
question_type="written",
difficulty="medium",
source="mimic",
reference_question="Q2?",
reference_answer="A2",
),
]
payload = asyncio.run(
pipeline.run(
context=ctx,
user_message="please quiz me",
num_questions=2,
templates_override=templates,
stream=bus,
)
)
assert explore_calls == [], "explore must NOT run when templates_override is set"
assert plan_calls == [], "plan must NOT run when templates_override is set"
assert [t.question_id for t in quiz_calls] == ["q_1", "q_2"]
assert payload["mode"] == "mimic"
assert payload["summary"]["source"] == "exam"
assert payload["summary"]["template_count"] == 2
# ---------------------------------------------------------------------------
# Exploration trace rendering + protocol-label stripping
# ---------------------------------------------------------------------------
def test_strip_protocol_label_removes_only_leading_label() -> None:
"""The trace renderer feeds each assistant message through this helper.
Only the leading protocol label should be stripped; later occurrences
(e.g., the model referencing ``THINK`` inside its own prose) must stay
so the rendered trace remains faithful."""
assert (
QuestionPipeline._strip_protocol_label(
"``THINK``\nfirst I need to check ``TOOL`` mounting."
)
== "first I need to check ``TOOL`` mounting."
)
assert QuestionPipeline._strip_protocol_label("``FINISH``\nDone.") == "Done."
assert QuestionPipeline._strip_protocol_label("no label here") == "no label here"
def test_render_exploration_trace_walks_messages_in_order() -> None:
"""Walks a synthetic post-initial message buffer and asserts the
rendered markdown contains an iteration block per assistant THINK,
one per tool_call, and one per role=tool result. The final FINISH
block must appear last so downstream phases read the closing
synthesis after the tool history."""
pipeline = _make_pipeline()
messages = [
{"role": "assistant", "content": "``THINK``\nI should retrieve the topic."},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_abc",
"type": "function",
"function": {
"name": "rag",
"arguments": json.dumps({"query": "eigenvalues", "kb_name": "demo"}),
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_abc",
"content": "Eigenvalues are scalars λ where Av = λv. [source-1]",
},
{"role": "user", "content": "(protocol nudge — should be filtered)"},
{"role": "assistant", "content": "``THINK``\nGood, I have grounding now."},
]
rendered = pipeline._render_exploration_trace(
messages, finish_text="I researched X; now let me generate 3 questions."
)
# Order: thought, tool call, tool result, thought, finish note. Find
# each header's index and assert ascending order.
indices = []
for marker in [
"Iteration 1 — Thought",
"Iteration 2 — Tool call: rag",
"Iteration 2 — Tool result (summarized): rag",
"Iteration 3 — Thought",
"Final exploration preface",
]:
idx = rendered.find(marker)
assert idx != -1, f"missing trace section: {marker!r}\n--- rendered ---\n{rendered}"
indices.append(idx)
assert indices == sorted(indices)
# The protocol nudge (role=user) must NOT bleed into the trace.
assert "protocol nudge" not in rendered
# Tool call args block must include the query verbatim.
assert "eigenvalues" in rendered
# The leading ``THINK`` label must be stripped from rendered thoughts.
assert "``THINK``" not in rendered
# FINISH text is at the bottom.
assert rendered.rstrip().endswith("I researched X; now let me generate 3 questions.")
def test_render_exploration_trace_empty_inputs_uses_marker() -> None:
pipeline = _make_pipeline()
rendered = pipeline._render_exploration_trace([], finish_text="")
# The YAML's empty marker should surface so the planner prompt isn't
# left with a dangling section header.
assert rendered.strip().startswith("(")
# ---------------------------------------------------------------------------
# Runtime config wiring
# ---------------------------------------------------------------------------
def test_runtime_config_overrides_max_iterations_and_summarizer_tokens() -> None:
"""``QuestionPipeline.__init__`` must honor the runtime_config payload
that ``DeepQuestionCapability`` builds via
``build_question_runtime_config``. A regression here means the
capability's config-driven knobs silently do nothing."""
pipeline = QuestionPipeline(
language="en",
runtime_config={
"exploring": {
"max_iterations": 12,
"tool_summarizer": {"enabled": False, "max_tokens": 1234},
}
},
)
assert pipeline.max_explore_iterations == 12
assert pipeline.tool_summarizer_enabled is False
assert pipeline.tool_summarizer_max_tokens == 1234
def test_runtime_config_falls_back_to_defaults_when_missing() -> None:
"""A missing / empty ``exploring`` block must not crash __init__; the
module-level defaults take over."""
from deeptutor.agents.question.pipeline import (
DEFAULT_MAX_EXPLORE_ITERATIONS,
DEFAULT_TOOL_SUMMARIZER_MAX_TOKENS,
)
pipeline = QuestionPipeline(language="en", runtime_config={})
assert pipeline.max_explore_iterations == DEFAULT_MAX_EXPLORE_ITERATIONS
assert pipeline.tool_summarizer_enabled is True
assert pipeline.tool_summarizer_max_tokens == DEFAULT_TOOL_SUMMARIZER_MAX_TOKENS
def test_build_question_runtime_config_reads_capabilities_section() -> None:
from deeptutor.agents.question.request_config import (
build_question_runtime_config,
)
rc = build_question_runtime_config(
base_config={
"capabilities": {
"deep_question": {
"exploring": {
"max_iterations": 10,
"tool_summarizer": {"enabled": False, "max_tokens": 500},
}
}
}
}
)
assert rc["exploring"]["max_iterations"] == 10
assert rc["exploring"]["tool_summarizer"]["enabled"] is False
assert rc["exploring"]["tool_summarizer"]["max_tokens"] == 500
def test_build_question_runtime_config_defaults_when_unconfigured() -> None:
from deeptutor.agents.question.request_config import (
build_question_runtime_config,
)
rc = build_question_runtime_config(base_config=None)
assert rc["exploring"]["max_iterations"] == 8
assert rc["exploring"]["tool_summarizer"]["enabled"] is True
assert rc["exploring"]["tool_summarizer"]["max_tokens"] == 800
# ---------------------------------------------------------------------------
# Tool Summarizer — substitution + streaming
# ---------------------------------------------------------------------------
def test_summarize_tool_result_streams_chunks_and_returns_assembled_text() -> None:
"""The summarizer must:
* Open a "Reflecting..." sub-trace node before streaming.
* Emit each model chunk to ``stream.thinking`` (so the trace panel
shows the compression happening live).
* Return the assembled summary text for the host to substitute into
the tool message buffer.
Regression target: if the streaming loop ever broke (e.g., chunks
weren't being appended), the host would silently swap the raw
tool_result with an empty string downstream.
"""
pipeline = _make_pipeline()
bus = _StubStreamBus()
# Capture thinking events too — the base stub only logs ``content`` /
# ``progress`` / ``error``. Add ``thinking`` here.
bus.thinking_events: list[dict[str, Any]] = [] # type: ignore[attr-defined]
async def _thinking(text, source="", stage="", metadata=None):
bus.thinking_events.append( # type: ignore[attr-defined]
{"text": text, "source": source, "stage": stage, "metadata": metadata or {}}
)
bus.thinking = _thinking # type: ignore[assignment]
# Build a minimal fake OpenAI client whose .chat.completions.create
# returns an async iterator of fake chunks (each with a single content
# delta), plus a trailing usage frame the summarizer ignores.
class _Delta:
def __init__(self, content: str | None) -> None:
self.content = content
class _Choice:
def __init__(self, content: str | None) -> None:
self.delta = _Delta(content)
class _Chunk:
def __init__(self, content: str | None, usage: Any = None) -> None:
self.choices = [_Choice(content)] if content is not None else []
self.usage = usage
async def _stream_chunks():
for piece in ["Eigenvalues are ", "scalars λ ", "where Av = λv."]:
yield _Chunk(piece)
# Trailing usage frame with no choices.
yield _Chunk(None, usage=None)
class _Completions:
async def create(self, **kwargs):
assert kwargs["stream"] is True
return _stream_chunks()
class _Chat:
def __init__(self) -> None:
self.completions = _Completions()
class _FakeClient:
def __init__(self) -> None:
self.chat = _Chat()
summary = asyncio.run(
pipeline._summarize_tool_result(
tool_name="rag",
tool_result="<long raw rag result here>",
iteration=1,
stream=bus,
client=_FakeClient(),
)
)
assert summary == "Eigenvalues are scalars λ where Av = λv."
# ``Reflecting...`` sub-trace opened (running) and closed (complete).
states = [ev["metadata"].get("call_state") for ev in bus.progress_events]
assert "running" in states
assert "complete" in states
# Each chunk became a ``thinking`` event with the reflecting trace_role.
assert bus.thinking_events # type: ignore[attr-defined]
roles = {ev["metadata"].get("trace_role") for ev in bus.thinking_events} # type: ignore[attr-defined]
assert roles == {"reflection"}
# Concatenated thinking text matches the returned summary.
joined = "".join(ev["text"] for ev in bus.thinking_events) # type: ignore[attr-defined]
assert joined == summary
def test_summarize_tool_result_empty_input_returns_none() -> None:
"""No LLM call should fire for an empty/whitespace result — and the
method must return None so the host keeps the original (empty) tool
message instead of substituting nothing."""
pipeline = _make_pipeline()
bus = _StubStreamBus()
class _DummyClient:
pass
result = asyncio.run(
pipeline._summarize_tool_result(
tool_name="rag",
tool_result=" ",
iteration=0,
stream=bus,
client=_DummyClient(),
)
)
assert result is None
# No streaming events at all — short-circuit before the model call.
assert bus.progress_events == []
# ---------------------------------------------------------------------------
# Backward-compat helpers that still need to exist for legacy callers
# ---------------------------------------------------------------------------
def test_parse_exam_paper_to_templates_happy_path(monkeypatch, tmp_path: Path) -> None:
"""End-to-end of the mimic adapter, mocking out MinerU + the question
extractor. Verifies that the JSON payload becomes a list of
``QuizTemplate`` with ``source="mimic"`` and the reference fields
populated."""
from deeptutor.agents.question import mimic_source
parsed_dir = tmp_path / "parsed-001"
parsed_dir.mkdir()
questions_file = parsed_dir / "exam_questions.json"
questions_file.write_text(
json.dumps(
{
"questions": [
{
"question_text": "Define an eigenvalue.",
"question_type": "written",
"answer": "A scalar λ such that Av = λv …",
},
{
"question_text": "What is the rank of an identity matrix?",
"question_type": "choice",
"answer": "n",
},
# Blank rows must be skipped, not crash the loop.
{"question_text": "", "question_type": "written"},
]
}
)
)
# "parsed" mode reads an already-parsed dir; it never invokes the parse
# layer, so only the question extractor needs stubbing.
monkeypatch.setattr(mimic_source, "extract_questions_from_paper", lambda *a, **k: True)
templates, trace = asyncio.run(
mimic_source.parse_exam_paper_to_templates(
parsed_dir,
max_questions=10,
paper_mode="parsed",
output_dir=tmp_path,
)
)
assert len(templates) == 2 # the blank row was filtered
assert all(t.source == "mimic" for t in templates)
assert templates[0].reference_question.startswith("Define")
assert templates[0].reference_answer.startswith("A scalar")
assert templates[1].question_type == "choice"
assert trace["template_count"] == "2"
assert trace["question_file"].endswith("exam_questions.json")