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
This commit is contained in:
1 parent
7acc0c5451
commit
e45b68bb8b
1089 files changed
+260614
No files matched your search
+149
@@ -0,0 +1,149 @@
|
||||
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)
|
||||
Reference in new issue
Block a user