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

296 lines
12 KiB
Python

from __future__ import annotations
import logging
import time
from typing import TYPE_CHECKING
import uuid
from deeptutor.learning.grading import classify_error, grade_answer
from deeptutor.learning.mastery import compute_mastery
from deeptutor.learning.models import (
ErrorRecord,
LearningModule,
LearningProgress,
LearningStage,
PendingQuestion,
QuizAttempt,
RetryAttempt,
)
from deeptutor.learning.storage import LearningStore
if TYPE_CHECKING:
from deeptutor.learning.scheduler import SpacedRepetitionScheduler
class LearningService:
def __init__(self, store: LearningStore | None = None) -> None:
self._store = store or LearningStore()
def get_or_create(self, book_id: str) -> LearningProgress:
existing = self._store.load(book_id)
if existing is not None:
return existing
progress = LearningProgress(book_id=book_id)
self._store.save(progress) # persist immediately to prevent race
return progress
def init_modules(self, progress: LearningProgress, modules: list[LearningModule]) -> None:
"""Initialize the runnable module set (replace semantics)."""
self.replace_modules(progress, modules)
def replace_modules(self, progress: LearningProgress, modules: list[LearningModule]) -> None:
"""Replace all modules and clean stale KP state."""
new_kp_ids = {kp.id for m in modules for kp in m.knowledge_points}
# Clean stale KP state
for key in list(progress.mastery_levels.keys()):
if key not in new_kp_ids:
del progress.mastery_levels[key]
for key in list(progress.knowledge_types.keys()):
if key not in new_kp_ids:
del progress.knowledge_types[key]
for key in list(progress.repetition_states.keys()):
if key not in new_kp_ids:
del progress.repetition_states[key]
progress.error_records = [
r for r in progress.error_records if r.knowledge_point_id in new_kp_ids
]
progress.feynman_retries = {
k: v for k, v in progress.feynman_retries.items() if k in new_kp_ids
}
progress.feynman_explanations = {
k: v for k, v in progress.feynman_explanations.items() if k in new_kp_ids
}
progress.review_queue = [
t for t in progress.review_queue if t.knowledge_point_id in new_kp_ids
]
# Clear global stage failure records — different modules should not share failure counts
progress.stage_failure_counts = {}
progress.stage_failure_notes = {}
# Set new modules
progress.modules = list(modules)
for mod in modules:
for kp in mod.knowledge_points:
progress.knowledge_types[kp.id] = kp.type
def advance_stage(self, progress: LearningProgress, next_stage: LearningStage) -> None:
progress.current_stage = next_stage
progress.updated_at = time.time()
def switch_module(self, progress: LearningProgress, module_id: str) -> bool:
"""Point the session at ``module_id`` and reset it to that module's
first teaching stage (EXPLAIN). Mutates ``progress`` in place and returns
whether the module exists. The caller is responsible for persisting
(``save``) — typically *after* cancelling any in-flight turn so the
turn's teardown cannot overwrite the switch with stale progress.
"""
found = any(m.id == module_id for m in progress.modules)
if found:
progress.current_module_id = module_id
progress.current_kp_index = 0
progress.current_stage = LearningStage.EXPLAIN
progress.updated_at = time.time()
return found
def record_quiz_attempt(self, progress: LearningProgress, attempt: QuizAttempt) -> None:
if not attempt.is_correct and attempt.error_type is not None:
# Find existing error record for this question + knowledge point.
existing = None
for rec in progress.error_records:
if (
rec.question_id == attempt.question_id
and rec.knowledge_point_id == attempt.knowledge_point_id
):
existing = rec
break
if existing is not None:
existing.retry_history.append(
RetryAttempt(
timestamp=time.time(),
is_correct=False,
attempt_number=len(existing.retry_history) + 1,
)
)
existing.status = "retrying"
else:
record = ErrorRecord(
id=uuid.uuid4().hex,
question_id=attempt.question_id,
knowledge_point_id=attempt.knowledge_point_id,
module_id=attempt.module_id,
error_type=attempt.error_type,
self_attribution=attempt.self_attribution,
status="active",
)
progress.error_records.append(record)
elif attempt.is_correct:
# Graduate any active error record for this question + knowledge point.
for rec in progress.error_records:
if (
rec.question_id == attempt.question_id
and rec.knowledge_point_id == attempt.knowledge_point_id
and rec.status in ("active", "retrying")
):
rec.retry_history.append(
RetryAttempt(
timestamp=time.time(),
is_correct=True,
attempt_number=len(rec.retry_history) + 1,
)
)
rec.status = "graduated"
break
progress.quiz_attempts.append(attempt)
progress.updated_at = time.time()
def calculate_mastery(self, progress: LearningProgress, kp_id: str) -> float:
"""Mastery 0..1 for *kp_id* from its attempt history (policy in mastery.py)."""
correctness = [
a.is_correct for a in progress.quiz_attempts if a.knowledge_point_id == kp_id
]
return compute_mastery(correctness)
def update_mastery(self, progress: LearningProgress, kp_id: str, level: float) -> None:
progress.mastery_levels[kp_id] = level
progress.updated_at = time.time()
def grade_and_record(
self,
progress: LearningProgress,
*,
question_id: str,
knowledge_point_id: str,
module_id: str,
user_answer: str,
expected_answer: str,
question_type: str = "short",
self_attribution: str = "",
scheduler: SpacedRepetitionScheduler | None = None,
) -> bool:
"""Grade one answer and fold it through the full post-answer pipeline.
record attempt -> recompute mastery -> advance the spaced-repetition
state -> rebuild the review queue -> persist. This is the single source
of truth for what happens when a student answers, shared by every
interactive stage. Grading is fail-closed: with no stored expected
answer the attempt is recorded wrong, never right.
"""
is_correct = bool(expected_answer) and grade_answer(
user_answer, expected_answer, question_type
)
self.record_quiz_attempt(
progress,
QuizAttempt(
question_id=question_id,
knowledge_point_id=knowledge_point_id,
module_id=module_id,
is_correct=is_correct,
user_answer=user_answer,
self_attribution=self_attribution,
error_type=None if is_correct else classify_error(user_answer),
),
)
if knowledge_point_id:
self.update_mastery(
progress, knowledge_point_id, self.calculate_mastery(progress, knowledge_point_id)
)
kp_type = progress.knowledge_types.get(knowledge_point_id)
if kp_type is not None and scheduler is not None:
state = progress.repetition_states.get(
knowledge_point_id
) or scheduler.get_initial_state(kp_type)
progress.repetition_states[knowledge_point_id] = state
scheduler.schedule_next(state, kp_type, is_correct)
progress.review_queue = scheduler.build_review_queue(progress)
self.save(progress)
return is_correct
# ── Loop-driven tutoring helpers ─────────────────────────────────────
def set_pending_question(self, progress: LearningProgress, pending: PendingQuestion) -> None:
"""Store the question the tutor just posed so its expected answer can
be graded deterministically on a later turn (never via the model)."""
progress.pending_question = pending
progress.updated_at = time.time()
self.save(progress)
def clear_pending_question(self, progress: LearningProgress) -> None:
progress.pending_question = None
progress.updated_at = time.time()
self.save(progress)
def record_qualitative(
self,
progress: LearningProgress,
kp_id: str,
*,
passed: bool,
evidence: str = "",
) -> None:
"""Record the qualitative (CONCEPT / DESIGN) gate outcome.
The boolean is the gate of record; ``mastery_levels`` is nudged only so
the map's colour matches the gate (full on pass, capped on fail).
"""
progress.qualitative_mastery[kp_id] = bool(passed)
current = progress.mastery_levels.get(kp_id, 0.0)
progress.mastery_levels[kp_id] = max(current, 1.0) if passed else min(current, 0.4)
if evidence:
progress.feynman_explanations[kp_id] = evidence
progress.updated_at = time.time()
self.save(progress)
def list_progress(self) -> dict:
"""Return summary of all book progress with per-book error info."""
logger = logging.getLogger(__name__)
book_ids = self._store.list_all()
summaries = []
errors = []
for bid in book_ids:
try:
progress = self._store.load(bid)
if progress is None:
continue
# Only count KPs from current modules (exclude stale IDs)
current_kp_ids = {kp.id for m in progress.modules for kp in m.knowledge_points}
total_kps = len(current_kp_ids)
total_mastery = sum(
progress.mastery_levels.get(kp_id, 0) for kp_id in current_kp_ids
)
# Derive display name from first module, fall back to book_id
display_name = ""
if progress.modules:
display_name = progress.modules[0].name or ""
summaries.append(
{
"book_id": progress.book_id,
"name": display_name or progress.book_id,
"modules_count": len(progress.modules),
"kp_count": total_kps,
"current_stage": progress.current_stage.value
if progress.current_stage
else "",
# Average mastery across current KPs (not the % of KPs mastered).
"avg_mastery_pct": round(total_mastery / total_kps * 100)
if total_kps
else 0,
"updated_at": progress.updated_at,
}
)
except Exception:
logger.warning("Failed to load progress for book %s, skipping", bid, exc_info=True)
errors.append({"book_id": bid, "error": "Failed to load"})
continue
return {"summaries": summaries, "errors": errors}
def save(self, progress: LearningProgress) -> None:
self._store.save(progress)
__all__ = ["LearningService"]