forked from cardosofelipe/fast-next-template
Implements procedural memory for learned skills and procedures: Core functionality: - ProceduralMemory class for procedure storage/retrieval - record_procedure with duplicate detection and step merging - find_matching for context-based procedure search - record_outcome for success/failure tracking - get_best_procedure for finding highest success rate - update_steps for procedure refinement Supporting modules: - ProcedureMatcher: Keyword-based procedure matching - MatchResult/MatchContext: Matching result types - Success rate weighting in match scoring Test coverage: - 43 unit tests covering all modules - matching.py: 97% coverage - memory.py: 86% coverage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
292 lines
8.3 KiB
Python
292 lines
8.3 KiB
Python
# app/services/memory/procedural/matching.py
|
|
"""
|
|
Procedure Matching.
|
|
|
|
Provides utilities for matching procedures to contexts,
|
|
ranking procedures by relevance, and suggesting procedures.
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, ClassVar
|
|
|
|
from app.services.memory.types import Procedure
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class MatchResult:
|
|
"""Result of a procedure match."""
|
|
|
|
procedure: Procedure
|
|
score: float
|
|
matched_terms: list[str] = field(default_factory=list)
|
|
match_type: str = "keyword" # keyword, semantic, pattern
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""Convert to dictionary."""
|
|
return {
|
|
"procedure_id": str(self.procedure.id),
|
|
"procedure_name": self.procedure.name,
|
|
"score": self.score,
|
|
"matched_terms": self.matched_terms,
|
|
"match_type": self.match_type,
|
|
"success_rate": self.procedure.success_rate,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class MatchContext:
|
|
"""Context for procedure matching."""
|
|
|
|
query: str
|
|
task_type: str | None = None
|
|
project_id: Any | None = None
|
|
agent_type_id: Any | None = None
|
|
max_results: int = 5
|
|
min_score: float = 0.3
|
|
require_success_rate: float | None = None
|
|
|
|
|
|
class ProcedureMatcher:
|
|
"""
|
|
Matches procedures to contexts using multiple strategies.
|
|
|
|
Matching strategies:
|
|
- Keyword matching on trigger pattern and name
|
|
- Pattern-based matching using regex
|
|
- Success rate weighting
|
|
|
|
In production, this would be augmented with vector similarity search.
|
|
"""
|
|
|
|
# Common task-related keywords for boosting
|
|
TASK_KEYWORDS: ClassVar[set[str]] = {
|
|
"create",
|
|
"update",
|
|
"delete",
|
|
"fix",
|
|
"implement",
|
|
"add",
|
|
"remove",
|
|
"refactor",
|
|
"test",
|
|
"deploy",
|
|
"configure",
|
|
"setup",
|
|
"build",
|
|
"debug",
|
|
"optimize",
|
|
}
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize the matcher."""
|
|
self._compiled_patterns: dict[str, re.Pattern[str]] = {}
|
|
|
|
def match(
|
|
self,
|
|
procedures: list[Procedure],
|
|
context: MatchContext,
|
|
) -> list[MatchResult]:
|
|
"""
|
|
Match procedures against a context.
|
|
|
|
Args:
|
|
procedures: List of procedures to match
|
|
context: Matching context
|
|
|
|
Returns:
|
|
List of match results, sorted by score (highest first)
|
|
"""
|
|
results: list[MatchResult] = []
|
|
|
|
query_terms = self._extract_terms(context.query)
|
|
query_lower = context.query.lower()
|
|
|
|
for procedure in procedures:
|
|
score, matched = self._calculate_match_score(
|
|
procedure=procedure,
|
|
query_terms=query_terms,
|
|
query_lower=query_lower,
|
|
context=context,
|
|
)
|
|
|
|
if score >= context.min_score:
|
|
# Apply success rate boost
|
|
if context.require_success_rate is not None:
|
|
if procedure.success_rate < context.require_success_rate:
|
|
continue
|
|
|
|
# Boost score based on success rate
|
|
success_boost = procedure.success_rate * 0.2
|
|
final_score = min(1.0, score + success_boost)
|
|
|
|
results.append(
|
|
MatchResult(
|
|
procedure=procedure,
|
|
score=final_score,
|
|
matched_terms=matched,
|
|
match_type="keyword",
|
|
)
|
|
)
|
|
|
|
# Sort by score descending
|
|
results.sort(key=lambda r: r.score, reverse=True)
|
|
|
|
return results[: context.max_results]
|
|
|
|
def _extract_terms(self, text: str) -> list[str]:
|
|
"""Extract searchable terms from text."""
|
|
# Remove special characters and split
|
|
clean = re.sub(r"[^\w\s-]", " ", text.lower())
|
|
terms = clean.split()
|
|
|
|
# Filter out very short terms
|
|
return [t for t in terms if len(t) >= 2]
|
|
|
|
def _calculate_match_score(
|
|
self,
|
|
procedure: Procedure,
|
|
query_terms: list[str],
|
|
query_lower: str,
|
|
context: MatchContext,
|
|
) -> tuple[float, list[str]]:
|
|
"""
|
|
Calculate match score between procedure and query.
|
|
|
|
Returns:
|
|
Tuple of (score, matched_terms)
|
|
"""
|
|
score = 0.0
|
|
matched: list[str] = []
|
|
|
|
trigger_lower = procedure.trigger_pattern.lower()
|
|
name_lower = procedure.name.lower()
|
|
|
|
# Exact name match - high score
|
|
if name_lower in query_lower or query_lower in name_lower:
|
|
score += 0.5
|
|
matched.append(f"name:{procedure.name}")
|
|
|
|
# Trigger pattern match
|
|
if trigger_lower in query_lower or query_lower in trigger_lower:
|
|
score += 0.4
|
|
matched.append(f"trigger:{procedure.trigger_pattern[:30]}")
|
|
|
|
# Term-by-term matching
|
|
for term in query_terms:
|
|
if term in trigger_lower:
|
|
score += 0.1
|
|
matched.append(term)
|
|
elif term in name_lower:
|
|
score += 0.08
|
|
matched.append(term)
|
|
|
|
# Boost for task keywords
|
|
if term in self.TASK_KEYWORDS:
|
|
if term in trigger_lower or term in name_lower:
|
|
score += 0.05
|
|
|
|
# Task type match if provided
|
|
if context.task_type:
|
|
task_type_lower = context.task_type.lower()
|
|
if task_type_lower in trigger_lower or task_type_lower in name_lower:
|
|
score += 0.3
|
|
matched.append(f"task_type:{context.task_type}")
|
|
|
|
# Regex pattern matching on trigger
|
|
try:
|
|
pattern = self._get_or_compile_pattern(trigger_lower)
|
|
if pattern and pattern.search(query_lower):
|
|
score += 0.25
|
|
matched.append("pattern_match")
|
|
except re.error:
|
|
pass # Invalid regex, skip pattern matching
|
|
|
|
return min(1.0, score), matched
|
|
|
|
def _get_or_compile_pattern(self, pattern: str) -> re.Pattern[str] | None:
|
|
"""Get or compile a regex pattern with caching."""
|
|
if pattern in self._compiled_patterns:
|
|
return self._compiled_patterns[pattern]
|
|
|
|
# Only compile if it looks like a regex pattern
|
|
if not any(c in pattern for c in r"\.*+?[]{}|()^$"):
|
|
return None
|
|
|
|
try:
|
|
compiled = re.compile(pattern, re.IGNORECASE)
|
|
self._compiled_patterns[pattern] = compiled
|
|
return compiled
|
|
except re.error:
|
|
return None
|
|
|
|
def rank_by_relevance(
|
|
self,
|
|
procedures: list[Procedure],
|
|
task_type: str,
|
|
) -> list[Procedure]:
|
|
"""
|
|
Rank procedures by relevance to a task type.
|
|
|
|
Args:
|
|
procedures: Procedures to rank
|
|
task_type: Task type for relevance
|
|
|
|
Returns:
|
|
Procedures sorted by relevance
|
|
"""
|
|
context = MatchContext(
|
|
query=task_type,
|
|
task_type=task_type,
|
|
min_score=0.0,
|
|
max_results=len(procedures),
|
|
)
|
|
|
|
results = self.match(procedures, context)
|
|
return [r.procedure for r in results]
|
|
|
|
def suggest_procedures(
|
|
self,
|
|
procedures: list[Procedure],
|
|
query: str,
|
|
min_success_rate: float = 0.5,
|
|
max_suggestions: int = 3,
|
|
) -> list[MatchResult]:
|
|
"""
|
|
Suggest the best procedures for a query.
|
|
|
|
Only suggests procedures with sufficient success rate.
|
|
|
|
Args:
|
|
procedures: Available procedures
|
|
query: Query/context
|
|
min_success_rate: Minimum success rate to suggest
|
|
max_suggestions: Maximum suggestions
|
|
|
|
Returns:
|
|
List of procedure suggestions
|
|
"""
|
|
context = MatchContext(
|
|
query=query,
|
|
max_results=max_suggestions,
|
|
min_score=0.2,
|
|
require_success_rate=min_success_rate,
|
|
)
|
|
|
|
return self.match(procedures, context)
|
|
|
|
|
|
# Singleton matcher instance
|
|
_matcher: ProcedureMatcher | None = None
|
|
|
|
|
|
def get_procedure_matcher() -> ProcedureMatcher:
|
|
"""Get the singleton procedure matcher instance."""
|
|
global _matcher
|
|
if _matcher is None:
|
|
_matcher = ProcedureMatcher()
|
|
return _matcher
|