Files
syndarix/backend/app/services/memory/procedural/matching.py
Felipe Cardoso b818f17418 feat(memory): add procedural memory implementation (Issue #92)
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>
2026-01-05 02:31:32 +01:00

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