feat(memory): Day M.1 complete - importance scoring system

- Add FrequencyTracker: increment(), get_frequency_score(), get_recency_score(), get_time_decay()
- Add EmotionAnalyzer: EMOTION_KEYWORDS dict, extract(), calculate_score(), get_emotion_profile()
- Add ImpactEvaluator: evaluate(), get_topic_overlap(), rank_by_impact()
- Add ImportanceScorer: composite scoring (freq 35% + recency 20% + emotion 25% + impact 20%)
- Update UserMemory model: frequency_count, emotion_tags, importance_score, importance_level, associated_topics
- Integrate ImportanceScorer into memory_service.py (recall + importance update)
- Add 37 tests for all memory scoring components
- Fix urgency patterns: remove overly broad '今天' that matched neutral text
- Update memory-update checklist: mark all M.1 tasks complete
This commit is contained in:
2026-04-05 13:22:23 +08:00
parent bfe3b6bb9d
commit 9bfa0dcc11
9 changed files with 1016 additions and 54 deletions

View File

@@ -0,0 +1,408 @@
"""
Tests for Importance Scoring System (M.1)
Tests: frequency tracking, emotion analysis, impact evaluation, and composite importance scoring.
"""
import pytest
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock
from app.services.memory.frequency_tracker import FrequencyTracker
from app.services.memory.emotion_analyzer import EmotionAnalyzer
from app.services.memory.impact_evaluator import ImpactEvaluator
from app.services.memory.importance_scorer import ImportanceScorer, ImportanceLevel
def create_mock_memory(
frequency_count: int = 0,
last_recalled_at=None,
content: str = "",
emotion_tags: list = None,
associated_topics: list = None,
last_accessed_at=None,
):
"""Create a mock UserMemory for testing"""
memory = MagicMock()
memory.frequency_count = frequency_count
memory.last_recalled_at = last_recalled_at
memory.content = content
memory.emotion_tags = emotion_tags or []
memory.associated_topics = associated_topics or []
memory.last_accessed_at = last_accessed_at
memory.importance_score = 0.5
memory.importance_level = "medium"
return memory
class TestFrequencyTracker:
"""Test frequency tracking"""
def test_increment(self):
tracker = FrequencyTracker()
memory = create_mock_memory(frequency_count=5)
result = tracker.increment(memory)
assert result.frequency_count == 6
assert result.last_recalled_at is not None
def test_increment_from_zero(self):
tracker = FrequencyTracker()
memory = create_mock_memory(frequency_count=0)
result = tracker.increment(memory)
assert result.frequency_count == 1
def test_get_frequency_score_zero(self):
tracker = FrequencyTracker()
memory = create_mock_memory(frequency_count=0)
score = tracker.get_frequency_score(memory)
assert score == 0.0
def test_get_frequency_score_normal(self):
tracker = FrequencyTracker()
memory = create_mock_memory(frequency_count=5)
score = tracker.get_frequency_score(memory)
# log(1+5) / log(1+10) ≈ log(6)/log(11) ≈ 0.778 / 1.041 ≈ 0.747
assert 0.7 < score < 0.8
def test_get_frequency_score_capped(self):
tracker = FrequencyTracker()
memory = create_mock_memory(frequency_count=100)
score = tracker.get_frequency_score(memory)
# Should be capped at 1.0
assert score <= 1.0
def test_get_recency_score_recent(self):
tracker = FrequencyTracker()
memory = create_mock_memory(last_recalled_at=datetime.now(UTC))
score = tracker.get_recency_score(memory)
assert score > 0.9
def test_get_recency_score_old(self):
tracker = FrequencyTracker()
old_date = datetime.now(UTC) - timedelta(days=60)
memory = create_mock_memory(last_recalled_at=old_date)
score = tracker.get_recency_score(memory)
# ~60 days old with 30-day half-life should be ~0.25
assert score < 0.3
def test_get_recency_score_never_recalled(self):
tracker = FrequencyTracker()
memory = create_mock_memory(last_recalled_at=None)
score = tracker.get_recency_score(memory)
assert score == 0.0
def test_get_time_decay(self):
tracker = FrequencyTracker()
recent = datetime.now(UTC) - timedelta(days=7)
memory = create_mock_memory(last_accessed_at=recent)
decay = tracker.get_time_decay(memory)
# ~7 days with 30-day half-life: exp(-7/30) ≈ 0.79
assert 0.7 < decay < 0.9
class TestEmotionAnalyzer:
"""Test emotion analysis"""
def test_extract_high_intensity(self):
analyzer = EmotionAnalyzer()
text = "这件事很重要,我急需解决!"
matched = analyzer.extract(text)
assert "很重要" in matched or "" in matched
def test_extract_worry(self):
analyzer = EmotionAnalyzer()
text = "我很担心这个问题"
matched = analyzer.extract(text)
assert "担心" in matched
def test_extract_no_emotion(self):
analyzer = EmotionAnalyzer()
text = "今天天气不错"
matched = analyzer.extract(text)
assert len(matched) == 0
def test_extract_urgency_pattern(self):
analyzer = EmotionAnalyzer()
text = "马上要迟到了"
matched = analyzer.extract(text)
assert any("URGENCY" in m for m in matched)
def test_calculate_score_high(self):
analyzer = EmotionAnalyzer()
memory = create_mock_memory(content="这件事非常重要,急需解决!")
score = analyzer.calculate_score(memory)
assert score >= 0.9
def test_calculate_score_neutral(self):
analyzer = EmotionAnalyzer()
memory = create_mock_memory(content="今天吃了苹果")
score = analyzer.calculate_score(memory)
assert score == 0.0
def test_calculate_score_from_emotion_tags(self):
analyzer = EmotionAnalyzer()
memory = create_mock_memory(emotion_tags=["", "很重要"])
score = analyzer.calculate_score(memory)
assert score >= 0.9
def test_get_emotion_profile(self):
analyzer = EmotionAnalyzer()
text = "我很担心这个问题,必须马上解决"
profile = analyzer.get_emotion_profile(text)
assert "matched_keywords" in profile
assert "max_weight" in profile
assert profile["max_weight"] > 0
class TestImpactEvaluator:
"""Test impact evaluation"""
def test_evaluate_no_topics(self):
evaluator = ImpactEvaluator()
memory = create_mock_memory(associated_topics=[])
score = evaluator.evaluate(memory)
assert score == 0.0
def test_evaluate_single_topic(self):
evaluator = ImpactEvaluator()
memory = create_mock_memory(associated_topics=["工作"])
score = evaluator.evaluate(memory)
# 1 topic / 5 threshold = 0.2
assert score == 0.2
def test_evaluate_full_topics(self):
evaluator = ImpactEvaluator()
memory = create_mock_memory(associated_topics=["工作", "健康", "家庭", "财务", "爱好"])
score = evaluator.evaluate(memory)
# 5 topics / 5 threshold = 1.0
assert score == 1.0
def test_evaluate_over_threshold(self):
evaluator = ImpactEvaluator()
memory = create_mock_memory(associated_topics=["a", "b", "c", "d", "e", "f", "g"])
score = evaluator.evaluate(memory)
# Capped at 1.0
assert score == 1.0
def test_get_topic_overlap(self):
evaluator = ImpactEvaluator()
memory_a = create_mock_memory(associated_topics=["工作", "健康", "家庭"])
memory_b = create_mock_memory(associated_topics=["工作", "健康", "爱好"])
overlap = evaluator.get_topic_overlap(memory_a, memory_b)
# Intersection: {工作, 健康} = 2, Union: {工作, 健康, 家庭, 爱好} = 4
# 2/4 = 0.5
assert overlap == 0.5
def test_rank_by_impact(self):
evaluator = ImpactEvaluator()
memory_low = create_mock_memory(associated_topics=["a"])
memory_high = create_mock_memory(associated_topics=["a", "b", "c", "d", "e"])
ranked = evaluator.rank_by_impact([memory_low, memory_high])
assert ranked[0] == memory_high
assert ranked[1] == memory_low
class TestImportanceScorer:
"""Test composite importance scoring"""
def test_calculate_score_fresh_memory(self):
scorer = ImportanceScorer()
memory = create_mock_memory(
frequency_count=0,
last_recalled_at=None,
content="今天吃了苹果",
associated_topics=[],
)
score = scorer.calculate_score(memory)
# All zeros, should be ~0
assert score < 0.1
def test_calculate_score_high_frequency(self):
scorer = ImportanceScorer()
memory = create_mock_memory(
frequency_count=10,
last_recalled_at=datetime.now(UTC),
content="工作相关",
associated_topics=["工作"],
)
score = scorer.calculate_score(memory)
# High frequency (log(11)/log(11) ≈ 1.0) * 0.35 + recency * 0.20 + emotion * 0.25 + impact * 0.20
# ≈ 0.35 + 0.20 + 0 + 0.04 = 0.59
assert score > 0.5
def test_calculate_score_with_emotion(self):
scorer = ImportanceScorer()
memory = create_mock_memory(
frequency_count=1,
content="这件事很重要,我急需解决!",
associated_topics=["工作"],
)
score = scorer.calculate_score(memory)
# Emotion score ~0.9 * 0.25 = 0.225
assert score > 0.2
def test_calculate_score_high_all_factors(self):
scorer = ImportanceScorer()
memory = create_mock_memory(
frequency_count=10,
last_recalled_at=datetime.now(UTC),
content="这个问题非常紧急,必须马上处理!",
associated_topics=["工作", "健康", "家庭", "财务", "爱好"],
)
score = scorer.calculate_score(memory)
# All factors high
assert score > 0.7
def test_get_importance_level_high(self):
scorer = ImportanceScorer()
level = scorer.get_importance_level(0.85)
assert level == ImportanceLevel.HIGH
def test_get_importance_level_medium(self):
scorer = ImportanceScorer()
level = scorer.get_importance_level(0.6)
assert level == ImportanceLevel.MEDIUM
def test_get_importance_level_low(self):
scorer = ImportanceScorer()
level = scorer.get_importance_level(0.3)
assert level == ImportanceLevel.LOW
def test_should_escalate_by_score(self):
scorer = ImportanceScorer()
memory = create_mock_memory(
frequency_count=10,
last_recalled_at=datetime.now(UTC),
content="紧急!非常重要!",
associated_topics=["a", "b", "c", "d", "e"],
)
result = scorer.should_escalate(memory)
# With high freq (0.35) + recent (0.20) + emotion (0.25) + many topics (0.20) = 1.0
assert result is True
def test_should_escalate_by_emotion(self):
scorer = ImportanceScorer()
memory = create_mock_memory(
frequency_count=1,
content="紧急!非常重要!",
associated_topics=[],
)
result = scorer.should_escalate(memory)
# Emotion intensity alone triggers escalation
assert result is True
def test_should_not_escalate(self):
scorer = ImportanceScorer()
memory = create_mock_memory(
frequency_count=0,
content="今天天气不错",
associated_topics=[],
)
result = scorer.should_escalate(memory)
assert result is False
def test_score_and_classify(self):
scorer = ImportanceScorer()
memory = create_mock_memory(frequency_count=10, associated_topics=["a", "b", "c", "d", "e"])
score, level = scorer.score_and_classify(memory)
assert isinstance(score, float)
assert 0.0 <= score <= 1.0
assert level in ImportanceLevel
def test_update_memory_importance(self):
scorer = ImportanceScorer()
memory = create_mock_memory(
frequency_count=10,
last_recalled_at=datetime.now(UTC),
content="这个问题非常重要!",
associated_topics=["工作", "健康"],
)
result = scorer.update_memory_importance(memory)
assert result.importance_score is not None
assert result.importance_level in ["high", "medium", "low"]
class TestImportanceLevel:
"""Test ImportanceLevel enum"""
def test_level_values(self):
assert ImportanceLevel.HIGH.value == "high"
assert ImportanceLevel.MEDIUM.value == "medium"
assert ImportanceLevel.LOW.value == "low"
def test_is_string_enum(self):
assert isinstance(ImportanceLevel.HIGH, str)
if __name__ == "__main__":
pytest.main([__file__, "-v"])