forked from cardosofelipe/fast-next-template
Add comprehensive scoring system with three strategies: - RelevanceScorer: Semantic similarity with keyword fallback - RecencyScorer: Exponential decay with type-specific half-lives - PriorityScorer: Priority-based scoring with type bonuses Implement CompositeScorer combining all strategies with configurable weights (default: 50% relevance, 30% recency, 20% priority). Add ContextRanker for budget-aware context selection with: - Greedy selection algorithm respecting token budgets - CRITICAL priority contexts always included - Diversity reranking to prevent source dominance - Comprehensive selection statistics 68 tests covering all scoring and ranking functionality. Part of #61 - Context Management Engine 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
142 lines
4.4 KiB
Python
142 lines
4.4 KiB
Python
"""
|
|
Recency Scorer for Context Management.
|
|
|
|
Scores context based on how recent it is.
|
|
More recent content gets higher scores.
|
|
"""
|
|
|
|
import math
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
from .base import BaseScorer
|
|
from ..types import BaseContext, ContextType
|
|
|
|
|
|
class RecencyScorer(BaseScorer):
|
|
"""
|
|
Scores context based on recency.
|
|
|
|
Uses exponential decay to score content based on age.
|
|
More recent content scores higher.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
weight: float = 1.0,
|
|
half_life_hours: float = 24.0,
|
|
type_half_lives: dict[ContextType, float] | None = None,
|
|
) -> None:
|
|
"""
|
|
Initialize recency scorer.
|
|
|
|
Args:
|
|
weight: Scorer weight for composite scoring
|
|
half_life_hours: Default hours until score decays to 0.5
|
|
type_half_lives: Optional context-type-specific half lives
|
|
"""
|
|
super().__init__(weight)
|
|
self._half_life_hours = half_life_hours
|
|
self._type_half_lives = type_half_lives or {}
|
|
|
|
# Set sensible defaults for context types
|
|
if ContextType.CONVERSATION not in self._type_half_lives:
|
|
self._type_half_lives[ContextType.CONVERSATION] = 1.0 # 1 hour
|
|
if ContextType.TOOL not in self._type_half_lives:
|
|
self._type_half_lives[ContextType.TOOL] = 0.5 # 30 minutes
|
|
if ContextType.KNOWLEDGE not in self._type_half_lives:
|
|
self._type_half_lives[ContextType.KNOWLEDGE] = 168.0 # 1 week
|
|
if ContextType.SYSTEM not in self._type_half_lives:
|
|
self._type_half_lives[ContextType.SYSTEM] = 720.0 # 30 days
|
|
if ContextType.TASK not in self._type_half_lives:
|
|
self._type_half_lives[ContextType.TASK] = 24.0 # 1 day
|
|
|
|
async def score(
|
|
self,
|
|
context: BaseContext,
|
|
query: str,
|
|
**kwargs: Any,
|
|
) -> float:
|
|
"""
|
|
Score context based on recency.
|
|
|
|
Args:
|
|
context: Context to score
|
|
query: Query (not used for recency, kept for interface)
|
|
**kwargs: Additional parameters
|
|
- reference_time: Time to measure recency from (default: now)
|
|
|
|
Returns:
|
|
Recency score between 0.0 and 1.0
|
|
"""
|
|
reference_time = kwargs.get("reference_time")
|
|
if reference_time is None:
|
|
reference_time = datetime.now(UTC)
|
|
elif reference_time.tzinfo is None:
|
|
reference_time = reference_time.replace(tzinfo=UTC)
|
|
|
|
# Ensure context timestamp is timezone-aware
|
|
context_time = context.timestamp
|
|
if context_time.tzinfo is None:
|
|
context_time = context_time.replace(tzinfo=UTC)
|
|
|
|
# Calculate age in hours
|
|
age = reference_time - context_time
|
|
age_hours = max(0, age.total_seconds() / 3600)
|
|
|
|
# Get half-life for this context type
|
|
context_type = context.get_type()
|
|
half_life = self._type_half_lives.get(context_type, self._half_life_hours)
|
|
|
|
# Exponential decay
|
|
decay_factor = math.exp(-math.log(2) * age_hours / half_life)
|
|
|
|
return self.normalize_score(decay_factor)
|
|
|
|
def get_half_life(self, context_type: ContextType) -> float:
|
|
"""
|
|
Get half-life for a context type.
|
|
|
|
Args:
|
|
context_type: Context type to get half-life for
|
|
|
|
Returns:
|
|
Half-life in hours
|
|
"""
|
|
return self._type_half_lives.get(context_type, self._half_life_hours)
|
|
|
|
def set_half_life(self, context_type: ContextType, hours: float) -> None:
|
|
"""
|
|
Set half-life for a context type.
|
|
|
|
Args:
|
|
context_type: Context type to set half-life for
|
|
hours: Half-life in hours
|
|
"""
|
|
if hours <= 0:
|
|
raise ValueError("Half-life must be positive")
|
|
self._type_half_lives[context_type] = hours
|
|
|
|
async def score_batch(
|
|
self,
|
|
contexts: list[BaseContext],
|
|
query: str,
|
|
**kwargs: Any,
|
|
) -> list[float]:
|
|
"""
|
|
Score multiple contexts.
|
|
|
|
Args:
|
|
contexts: Contexts to score
|
|
query: Query (not used)
|
|
**kwargs: Additional parameters
|
|
|
|
Returns:
|
|
List of scores (same order as input)
|
|
"""
|
|
scores = []
|
|
for context in contexts:
|
|
score = await self.score(context, query, **kwargs)
|
|
scores.append(score)
|
|
return scores
|