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

161 lines
5.9 KiB
Python

"""Exam-paper → QuizTemplate adapter for mimic mode.
Wraps the (sync, IO-heavy) MinerU parsing backend (local CLI or cloud API,
selected via ``document_parsing.json``) + the LLM question extractor so the capability
layer can hand mimic templates to :class:`QuestionPipeline` via its
``templates_override`` entry. Each extracted question carries its own
``question_type`` and ``difficulty`` (classified by the extractor), so mimic
templates preserve the source paper's format mix instead of defaulting every
item to a written question.
This module is intentionally narrow: it ONLY converts a PDF (or a
previously-parsed working directory) into a list of
:class:`QuizTemplate`. Streaming progress, prompt assembly, LLM calls,
and result emission all stay in the pipeline / capability layers.
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
import json
import logging
from pathlib import Path
from deeptutor.agents.question.pipeline import (
_VALID_DIFFICULTIES,
_VALID_QUESTION_TYPES,
QuizTemplate,
)
from deeptutor.services.parsing import get_parse_service
from deeptutor.tools.question.question_extractor import extract_questions_from_paper
logger = logging.getLogger(__name__)
_DEFAULT_DIFFICULTY = "medium"
_DEFAULT_QUESTION_TYPE = "written"
_TOPIC_CLIP_CHARS = 240
def _coerce_question_type(raw: object) -> str:
"""Map the extractor's per-question type onto the canonical taxonomy.
The classification authority lives here (agents layer) rather than in the
tools-layer extractor, which only emits a best-effort string. Anything
outside the canonical set degrades to ``written`` (a free-text answer),
the safest catch-all for an unrecognized format."""
value = str(raw or "").strip().lower()
return value if value in _VALID_QUESTION_TYPES else _DEFAULT_QUESTION_TYPE
def _coerce_difficulty(raw: object) -> str:
"""Validate the extractor's per-question difficulty; default ``medium``."""
value = str(raw or "").strip().lower()
return value if value in _VALID_DIFFICULTIES else _DEFAULT_DIFFICULTY
async def parse_exam_paper_to_templates(
paper_path: str | Path,
*,
max_questions: int,
paper_mode: str,
output_dir: str | Path,
progress_callback: Callable[[str], None] | None = None,
) -> tuple[list[QuizTemplate], dict[str, str]]:
"""Resolve an exam paper into a list of mimic-mode ``QuizTemplate``\\ s.
``paper_mode``:
* ``"upload"`` — ``paper_path`` is a freshly-uploaded PDF; the active
MinerU backend (local CLI or cloud API) parses it under ``output_dir``.
* ``"parsed"`` — ``paper_path`` is a previously-parsed working dir
(already contains the MinerU output); skip the parse step.
Returns ``(templates, trace)``. ``trace`` carries paths + counts for
inclusion in the final ``stream.result`` envelope. ``progress_callback``
is a plain sync callable invoked from the parser worker thread with live
parsing progress lines (upload mode only — the parsed path has nothing to
report). Raises :class:`MinerUError` (a ``RuntimeError``) when parsing or
extraction fails — the caller emits a user-facing error.
"""
return await asyncio.to_thread(
_parse_sync,
Path(paper_path),
int(max_questions),
str(paper_mode),
Path(output_dir),
progress_callback,
)
def _parse_sync(
paper_path: Path,
max_questions: int,
paper_mode: str,
output_base: Path,
progress_callback: Callable[[str], None] | None = None,
) -> tuple[list[QuizTemplate], dict[str, str]]:
output_base.mkdir(parents=True, exist_ok=True)
if paper_mode == "parsed":
# Caller already has a parsed directory; skip the parse step. Its own
# dir doubles as the questions-output dir (legacy behavior).
working_dir = paper_path
questions_dir = working_dir
else:
# Shared parse layer: cached + engine-pluggable (the active engine is
# selected in Settings → Document Parsing). Returns the cache dir with
# the parsed artifacts; the questions JSON goes to the session output
# dir so it never pollutes the shared parse cache.
doc = get_parse_service().parse(paper_path, on_output=progress_callback)
working_dir = doc.workdir or paper_path
questions_dir = output_base
json_files = list(questions_dir.glob("*_questions.json"))
if not json_files:
ok = extract_questions_from_paper(
str(working_dir),
output_dir=None if questions_dir == working_dir else str(questions_dir),
)
if not ok:
raise RuntimeError("Failed to extract questions from parsed exam")
json_files = list(questions_dir.glob("*_questions.json"))
if not json_files:
raise RuntimeError("Question extraction output not found")
with json_files[0].open(encoding="utf-8") as fh:
payload = json.load(fh)
questions = payload.get("questions") or []
if max_questions > 0:
questions = questions[:max_questions]
templates: list[QuizTemplate] = []
for idx, item in enumerate(questions, 1):
if not isinstance(item, dict):
continue
q_text = str(item.get("question_text") or "").strip()
if not q_text:
continue
templates.append(
QuizTemplate(
question_id=f"q_{idx}",
topic=q_text[:_TOPIC_CLIP_CHARS],
question_type=_coerce_question_type(item.get("question_type")),
difficulty=_coerce_difficulty(item.get("difficulty")),
source="mimic",
reference_question=q_text,
reference_answer=str(item.get("answer") or "").strip() or None,
)
)
trace = {
"paper_dir": str(working_dir),
"question_file": str(json_files[0]),
"template_count": str(len(templates)),
}
return templates, trace
__all__ = ["parse_exam_paper_to_templates"]