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:
Z User committed 2026-10-27 22:50:50 +00:00
1 parent 7acc0c5451
commit e45b68bb8b
1089 files changed
+260614

No files matched your search

+149
View File
@@ -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)