Files
AOS/skills/quiz-mastery/src/quiz_mastery/service.py
T
Z User e45b68bb8b feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)
Real IMAP connection via imapflow library:
- Connects to IMAP server (IMAPFlow, TLS on port 993)
- Fetches recent messages (configurable limit, default 20)
- Extracts: from, fromEmail, subject, date, snippet (200 chars)
- Thread-to-contact matching (PRD §6.1):
  1. Match sender email against persons table
  2. If no person match, extract domain and match against company.website
  3. Returns matchedCompany + matchedPerson for each email
- Audit-logged (action: crm.mailsync:imap)
- Zod validated (host, port, user, password, folder, limit)
- 502 on IMAP connection failure with detail
- 400 on validation failure

Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
2026-10-27 22:50:50 +00:00

305 lines
11 KiB
Python
Executable File

from __future__ import annotations
from dataclasses import asdict
from pathlib import Path
import uuid
import json
from .models import KnowledgePoint, KnowledgeSource, Question, QuizSession, MasteryRecord
from .repository import JsonRepository
from .quiz_generator import QuizGenerator
from .evaluator import Evaluator
from .mastery_engine import MasteryEngine
from .planner import Planner
class QuizMasteryService:
def __init__(self, base_dir: str | Path):
self.repo = JsonRepository(base_dir)
self.generator = QuizGenerator()
self.evaluator = Evaluator()
self.mastery_engine = MasteryEngine()
self.planner = Planner()
# ── Knowledge Points ──────────────────────────────────────────
def load_knowledge_points(self, document_id: str) -> list[KnowledgePoint]:
"""Load knowledge points from JSON file."""
raw = self.repo.load_json(
self.repo.knowledge_points_path(document_id), default={}
)
items = raw.get("knowledge_points", [])
result: list[KnowledgePoint] = []
for item in items:
source_data = item.get("source")
kp = KnowledgePoint(
id=item["id"],
title=item["title"],
description=item.get("description", ""),
definition=item.get("definition", ""),
tags=item.get("tags", []),
source=KnowledgeSource(**source_data) if source_data else None,
)
result.append(kp)
return result
def save_knowledge_points(
self, document_id: str, knowledge_points_data: list[dict]
) -> None:
"""Save extracted knowledge points to JSON.
Args:
document_id: Document identifier.
knowledge_points_data: List of dicts, each with id, title,
definition, description, tags.
"""
payload = {"knowledge_points": knowledge_points_data}
self.repo.save_json(
self.repo.knowledge_points_path(document_id), payload
)
# ── Progress ──────────────────────────────────────────────────
def load_progress(
self, user_id: str, document_id: str
) -> dict[str, MasteryRecord]:
"""Load user mastery progress from JSON."""
raw = self.repo.load_json(
self.repo.progress_path(user_id, document_id), default={}
)
mastery_records = raw.get("mastery_records", {})
parsed: dict[str, MasteryRecord] = {}
for kp_id, record in mastery_records.items():
mr = MasteryRecord(knowledge_point_id=kp_id)
mr.current_level = record.get("current_level", 1)
mr.attempts = record.get("attempts", 0)
mr.last_accuracy = record.get("last_accuracy", 0.0)
mr.best_accuracy = record.get("best_accuracy", 0.0)
mr.last_reviewed_at = record.get("last_reviewed_at")
mr.is_weak = record.get("is_weak", False)
mr.accuracy_history = record.get("accuracy_history", [])
mr.next_review_at = record.get("next_review_at")
mr.review_stage = record.get("review_stage", 0)
parsed[kp_id] = mr
return parsed
def save_progress(
self,
user_id: str,
document_id: str,
records: dict[str, MasteryRecord],
) -> None:
"""Save user mastery progress to JSON."""
payload = {
"user_id": user_id,
"document_id": document_id,
"mastery_records": {
kp_id: asdict(record) for kp_id, record in records.items()
},
}
self.repo.save_json(
self.repo.progress_path(user_id, document_id), payload
)
# ── Quiz Generation ───────────────────────────────────────────
def generate_quiz_for_user(
self,
user_id: str,
document_id: str,
knowledge_point_ids: list[str] | None = None,
level: int | None = None,
num_questions: int | None = None,
) -> dict:
"""Generate quiz prompts for given knowledge points.
If knowledge_point_ids is None, uses all knowledge points.
If level is None, reads current_level from mastery records
(first-time defaults to 1).
Returns dict with 'prompts' (system_prompt + user_prompt),
'knowledge_points' used, and 'level'.
"""
knowledge_points = self.load_knowledge_points(document_id)
progress = self.load_progress(user_id, document_id)
if knowledge_point_ids:
selected = [kp for kp in knowledge_points if kp.id in knowledge_point_ids]
else:
selected = knowledge_points
if not selected:
return {"error": "No knowledge points found"}
# Determine level per knowledge point group
# Use the most common level or explicit level
if level is not None:
quiz_level = max(1, min(3, level))
else:
# Determine from mastery records; first-time = 1
levels = []
for kp in selected:
record = progress.get(kp.id)
if record is None:
levels.append(1) # First time → L1
else:
levels.append(record.current_level)
# Use the minimum level among selected (conservative)
quiz_level = min(levels) if levels else 1
prompts = self.generator.generate_quiz(
selected, level=quiz_level, num_questions=num_questions
)
return {
"document_id": document_id,
"user_id": user_id,
"level": quiz_level,
"knowledge_point_ids": [kp.id for kp in selected],
"prompts": prompts,
}
# ── Quiz Submission ───────────────────────────────────────────
def submit_quiz_answers(
self,
user_id: str,
document_id: str,
session_id: str,
answers: dict[str, str],
) -> dict:
"""Submit answers for a quiz session, evaluate, and update mastery."""
session_data = self.repo.load_json(
self.repo.session_path(session_id), default=None
)
if not session_data:
raise FileNotFoundError(f"Quiz session not found: {session_id}")
questions = []
for item in session_data["questions"]:
questions.append(Question(**item))
results = self.evaluator.evaluate_answers(questions, answers)
progress = self.load_progress(user_id, document_id)
updated = self.mastery_engine.update_mastery(progress, questions, results)
self.save_progress(user_id, document_id, updated)
# Calculate score (excluding short_answer with score=None)
scored_results = [r for r in results if r.score is not None]
score = sum(r.score for r in scored_results)
total = len(scored_results)
needs_review_count = sum(1 for r in results if r.needs_review)
summary = {
"session_id": session_id,
"score": score,
"total": total,
"accuracy": score / total if total else 0.0,
"needs_review_count": needs_review_count,
"results": [asdict(r) for r in results],
}
# Update session data
session_data["answers"] = answers
session_data["results"] = summary["results"]
session_data["status"] = "completed"
self.repo.save_json(self.repo.session_path(session_id), session_data)
return summary
# ── Import Questions ──────────────────────────────────────────
def import_questions(
self,
document_id: str,
user_id: str,
questions_data: list[dict],
) -> dict:
"""Import parsed questions and create a quiz session.
Args:
document_id: Document identifier.
user_id: User identifier.
questions_data: List of question dicts (from LLM parsing).
Returns:
dict with session_id and questions.
"""
questions: list[Question] = []
for item in questions_data:
q = Question(
id=item.get("id", f"q_{uuid.uuid4().hex[:8]}"),
knowledge_point_ids=item.get("knowledge_point_ids", []),
level=item.get("level", 1),
type=item.get("type", "single_choice"),
prompt=item.get("prompt", ""),
options=item.get("options", []),
answer=item.get("answer"),
explanation=item.get("explanation", ""),
source_refs=item.get("source_refs", []),
)
questions.append(q)
session_id = f"quiz_{uuid.uuid4().hex[:12]}"
kp_ids = list(
set(kp_id for q in questions for kp_id in q.knowledge_point_ids)
)
level = questions[0].level if questions else 1
session = QuizSession(
session_id=session_id,
user_id=user_id,
document_id=document_id,
level=level,
knowledge_point_ids=kp_ids,
questions=questions,
)
self.repo.save_json(
self.repo.session_path(session_id),
asdict(session),
)
return {
"session_id": session_id,
"document_id": document_id,
"level": level,
"num_questions": len(questions),
"questions": [asdict(q) for q in questions],
}
# ── Review Candidates ─────────────────────────────────────────
def get_review_candidates(
self, user_id: str, document_id: str, today_str: str | None = None
) -> list[dict]:
"""Get review recommendations for a user.
Returns list of knowledge points that need review.
"""
from datetime import datetime
if today_str is None:
today_str = datetime.now().strftime("%Y-%m-%d")
knowledge_points = self.load_knowledge_points(document_id)
progress = self.load_progress(user_id, document_id)
return self.planner.recommend_review(knowledge_points, progress, today_str)
# ── User Progress ─────────────────────────────────────────────
def get_user_progress(self, user_id: str, document_id: str) -> dict:
"""Get user's mastery progress summary."""
progress = self.load_progress(user_id, document_id)
return {
"user_id": user_id,
"document_id": document_id,
"mastery_records": {
kp_id: asdict(record) for kp_id, record in progress.items()
},
}