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
150 lines
5.3 KiB
Python
Executable File
150 lines
5.3 KiB
Python
Executable File
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
from .models import MasteryRecord, Question, AnswerResult
|
|
|
|
|
|
# Ebbinghaus forgetting curve intervals in days
|
|
REVIEW_INTERVALS = [1, 2, 4, 7, 15]
|
|
|
|
# Maximum accuracy history entries to keep
|
|
MAX_ACCURACY_HISTORY = 10
|
|
|
|
|
|
class MasteryEngine:
|
|
"""Tracks mastery level, weak points, and spaced repetition schedule."""
|
|
|
|
def update_mastery(
|
|
self,
|
|
existing_records: dict[str, MasteryRecord],
|
|
questions: list[Question],
|
|
results: list[AnswerResult],
|
|
) -> dict[str, MasteryRecord]:
|
|
"""Update mastery records based on quiz results.
|
|
|
|
- Correct answer at current level → level up (max 3)
|
|
- Wrong answer → level down (min 1)
|
|
- First time → forced to level 1
|
|
- Weak marking: accuracy < 0.5 or 2 consecutive wrong → is_weak = True
|
|
- Strong recovery: accuracy >= 0.8 for 2 consecutive → is_weak = False
|
|
- Updates Ebbinghaus review schedule
|
|
"""
|
|
question_map = {q.id: q for q in questions}
|
|
now_str = datetime.now().strftime("%Y-%m-%d")
|
|
|
|
# Group results by knowledge point
|
|
kp_scores: dict[str, list[float]] = {}
|
|
for r in results:
|
|
q = question_map.get(r.question_id)
|
|
if q is None:
|
|
continue
|
|
# Skip short_answer that needs review (score is None)
|
|
if r.score is None:
|
|
continue
|
|
for kp_id in q.knowledge_point_ids:
|
|
if kp_id not in kp_scores:
|
|
kp_scores[kp_id] = []
|
|
kp_scores[kp_id].append(r.score)
|
|
|
|
for kp_id, scores in kp_scores.items():
|
|
record = existing_records.get(kp_id)
|
|
if record is None:
|
|
record = MasteryRecord(knowledge_point_id=kp_id, current_level=1)
|
|
existing_records[kp_id] = record
|
|
|
|
accuracy = sum(scores) / len(scores) if scores else 0.0
|
|
|
|
# Update basic stats
|
|
record.attempts += 1
|
|
record.last_accuracy = accuracy
|
|
record.best_accuracy = max(record.best_accuracy, accuracy)
|
|
record.last_reviewed_at = now_str
|
|
|
|
# Update accuracy history (keep last 10)
|
|
record.accuracy_history.append(accuracy)
|
|
if len(record.accuracy_history) > MAX_ACCURACY_HISTORY:
|
|
record.accuracy_history = record.accuracy_history[-MAX_ACCURACY_HISTORY:]
|
|
|
|
# Update level: correct → up, wrong → down
|
|
is_correct = accuracy >= 0.6 # threshold for "correct" at current level
|
|
if is_correct:
|
|
record.current_level = min(record.current_level + 1, 3)
|
|
else:
|
|
record.current_level = max(record.current_level - 1, 1)
|
|
|
|
# Update weak status
|
|
self._update_weak_status(record)
|
|
|
|
# Update Ebbinghaus review schedule
|
|
self._update_review_schedule(record, is_correct, now_str)
|
|
|
|
return existing_records
|
|
|
|
def _update_weak_status(self, record: MasteryRecord) -> None:
|
|
"""Mark or unmark a knowledge point as weak.
|
|
|
|
Weak if: accuracy < 0.5 OR last 2 attempts both wrong
|
|
Recover if: accuracy >= 0.8 for last 2 consecutive attempts
|
|
"""
|
|
history = record.accuracy_history
|
|
|
|
# Check for consecutive failures (last 2 both < 0.6)
|
|
if len(history) >= 2 and history[-1] < 0.6 and history[-2] < 0.6:
|
|
record.is_weak = True
|
|
return
|
|
|
|
# Check overall recent accuracy
|
|
if record.last_accuracy < 0.5:
|
|
record.is_weak = True
|
|
return
|
|
|
|
# Check for recovery: last 2 both >= 0.8
|
|
if len(history) >= 2 and history[-1] >= 0.8 and history[-2] >= 0.8:
|
|
record.is_weak = False
|
|
|
|
def _update_review_schedule(
|
|
self, record: MasteryRecord, is_correct: bool, today_str: str
|
|
) -> None:
|
|
"""Update Ebbinghaus spaced repetition schedule.
|
|
|
|
Correct → advance review_stage (max 4)
|
|
Wrong → reset review_stage to 0
|
|
"""
|
|
if is_correct:
|
|
record.review_stage = min(record.review_stage + 1, len(REVIEW_INTERVALS) - 1)
|
|
else:
|
|
record.review_stage = 0
|
|
|
|
interval_days = REVIEW_INTERVALS[record.review_stage]
|
|
today = datetime.strptime(today_str, "%Y-%m-%d")
|
|
next_review = today + timedelta(days=interval_days)
|
|
record.next_review_at = next_review.strftime("%Y-%m-%d")
|
|
|
|
def get_review_candidates(
|
|
self,
|
|
records: dict[str, MasteryRecord],
|
|
today_str: str,
|
|
) -> list[str]:
|
|
"""Return knowledge point IDs that need review.
|
|
|
|
Criteria:
|
|
- next_review_at <= today (due for review)
|
|
- is_weak=True and last_reviewed_at within last 3 days
|
|
"""
|
|
candidates = set()
|
|
today = datetime.strptime(today_str, "%Y-%m-%d")
|
|
three_days_ago = (today - timedelta(days=3)).strftime("%Y-%m-%d")
|
|
|
|
for kp_id, record in records.items():
|
|
# Due for review based on Ebbinghaus schedule
|
|
if record.next_review_at and record.next_review_at <= today_str:
|
|
candidates.add(kp_id)
|
|
|
|
# Weak and recently reviewed (within 3 days)
|
|
if record.is_weak and record.last_reviewed_at:
|
|
if record.last_reviewed_at >= three_days_ago:
|
|
candidates.add(kp_id)
|
|
|
|
return list(candidates)
|