forked from cardosofelipe/fast-next-template
- Add _escape_like_pattern() helper to escape SQL wildcards (%, _, \) - Apply escaping in SemanticMemory.search_facts and get_by_entity - Apply escaping in ProceduralMemory.search and find_best_for_task Prevents attackers from injecting SQL wildcard patterns through user-controlled search terms. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
750 lines
24 KiB
Python
750 lines
24 KiB
Python
# app/services/memory/procedural/memory.py
|
|
"""
|
|
Procedural Memory Implementation.
|
|
|
|
Provides storage and retrieval for learned procedures (skills)
|
|
derived from successful task execution patterns.
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy import and_, desc, or_, select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.memory.procedure import Procedure as ProcedureModel
|
|
from app.services.memory.config import get_memory_settings
|
|
from app.services.memory.types import Procedure, ProcedureCreate, RetrievalResult, Step
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _escape_like_pattern(pattern: str) -> str:
|
|
"""
|
|
Escape SQL LIKE/ILIKE special characters to prevent pattern injection.
|
|
|
|
Characters escaped:
|
|
- % (matches zero or more characters)
|
|
- _ (matches exactly one character)
|
|
- \\ (escape character itself)
|
|
|
|
Args:
|
|
pattern: Raw search pattern from user input
|
|
|
|
Returns:
|
|
Escaped pattern safe for use in LIKE/ILIKE queries
|
|
"""
|
|
# Escape backslash first, then the wildcards
|
|
return pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
|
|
|
|
def _model_to_procedure(model: ProcedureModel) -> Procedure:
|
|
"""Convert SQLAlchemy model to Procedure dataclass."""
|
|
return Procedure(
|
|
id=model.id, # type: ignore[arg-type]
|
|
project_id=model.project_id, # type: ignore[arg-type]
|
|
agent_type_id=model.agent_type_id, # type: ignore[arg-type]
|
|
name=model.name, # type: ignore[arg-type]
|
|
trigger_pattern=model.trigger_pattern, # type: ignore[arg-type]
|
|
steps=model.steps or [], # type: ignore[arg-type]
|
|
success_count=model.success_count, # type: ignore[arg-type]
|
|
failure_count=model.failure_count, # type: ignore[arg-type]
|
|
last_used=model.last_used, # type: ignore[arg-type]
|
|
embedding=None, # Don't expose raw embedding
|
|
created_at=model.created_at, # type: ignore[arg-type]
|
|
updated_at=model.updated_at, # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
class ProceduralMemory:
|
|
"""
|
|
Procedural Memory Service.
|
|
|
|
Provides procedure storage and retrieval:
|
|
- Record procedures from successful task patterns
|
|
- Find matching procedures by trigger pattern
|
|
- Track success/failure rates
|
|
- Get best procedure for a task type
|
|
- Update procedure steps
|
|
|
|
Performance target: <50ms P95 for matching
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
session: AsyncSession,
|
|
embedding_generator: Any | None = None,
|
|
) -> None:
|
|
"""
|
|
Initialize procedural memory.
|
|
|
|
Args:
|
|
session: Database session
|
|
embedding_generator: Optional embedding generator for semantic matching
|
|
"""
|
|
self._session = session
|
|
self._embedding_generator = embedding_generator
|
|
self._settings = get_memory_settings()
|
|
|
|
@classmethod
|
|
async def create(
|
|
cls,
|
|
session: AsyncSession,
|
|
embedding_generator: Any | None = None,
|
|
) -> "ProceduralMemory":
|
|
"""
|
|
Factory method to create ProceduralMemory.
|
|
|
|
Args:
|
|
session: Database session
|
|
embedding_generator: Optional embedding generator
|
|
|
|
Returns:
|
|
Configured ProceduralMemory instance
|
|
"""
|
|
return cls(session=session, embedding_generator=embedding_generator)
|
|
|
|
# =========================================================================
|
|
# Procedure Recording
|
|
# =========================================================================
|
|
|
|
async def record_procedure(self, procedure: ProcedureCreate) -> Procedure:
|
|
"""
|
|
Record a new procedure or update an existing one.
|
|
|
|
If a procedure with the same name exists in the same scope,
|
|
its steps will be updated and success count incremented.
|
|
|
|
Args:
|
|
procedure: Procedure data to record
|
|
|
|
Returns:
|
|
The created or updated procedure
|
|
"""
|
|
# Check for existing procedure with same name
|
|
existing = await self._find_existing_procedure(
|
|
project_id=procedure.project_id,
|
|
agent_type_id=procedure.agent_type_id,
|
|
name=procedure.name,
|
|
)
|
|
|
|
if existing is not None:
|
|
# Update existing procedure
|
|
return await self._update_existing_procedure(
|
|
existing=existing,
|
|
new_steps=procedure.steps,
|
|
new_trigger=procedure.trigger_pattern,
|
|
)
|
|
|
|
# Create new procedure
|
|
now = datetime.now(UTC)
|
|
|
|
# Generate embedding if possible
|
|
embedding = None
|
|
if self._embedding_generator is not None:
|
|
embedding_text = self._create_embedding_text(procedure)
|
|
embedding = await self._embedding_generator.generate(embedding_text)
|
|
|
|
model = ProcedureModel(
|
|
project_id=procedure.project_id,
|
|
agent_type_id=procedure.agent_type_id,
|
|
name=procedure.name,
|
|
trigger_pattern=procedure.trigger_pattern,
|
|
steps=procedure.steps,
|
|
success_count=1, # New procedures start with 1 success (they worked)
|
|
failure_count=0,
|
|
last_used=now,
|
|
embedding=embedding,
|
|
)
|
|
|
|
self._session.add(model)
|
|
await self._session.flush()
|
|
await self._session.refresh(model)
|
|
|
|
logger.info(
|
|
f"Recorded new procedure: {procedure.name} with {len(procedure.steps)} steps"
|
|
)
|
|
|
|
return _model_to_procedure(model)
|
|
|
|
async def _find_existing_procedure(
|
|
self,
|
|
project_id: UUID | None,
|
|
agent_type_id: UUID | None,
|
|
name: str,
|
|
) -> ProcedureModel | None:
|
|
"""Find an existing procedure with the same name in the same scope."""
|
|
query = select(ProcedureModel).where(ProcedureModel.name == name)
|
|
|
|
if project_id is not None:
|
|
query = query.where(ProcedureModel.project_id == project_id)
|
|
else:
|
|
query = query.where(ProcedureModel.project_id.is_(None))
|
|
|
|
if agent_type_id is not None:
|
|
query = query.where(ProcedureModel.agent_type_id == agent_type_id)
|
|
else:
|
|
query = query.where(ProcedureModel.agent_type_id.is_(None))
|
|
|
|
result = await self._session.execute(query)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def _update_existing_procedure(
|
|
self,
|
|
existing: ProcedureModel,
|
|
new_steps: list[dict[str, Any]],
|
|
new_trigger: str,
|
|
) -> Procedure:
|
|
"""Update an existing procedure with new steps."""
|
|
now = datetime.now(UTC)
|
|
|
|
# Merge steps intelligently - keep existing order, add new steps
|
|
merged_steps = self._merge_steps(
|
|
existing.steps or [], # type: ignore[arg-type]
|
|
new_steps,
|
|
)
|
|
|
|
stmt = (
|
|
update(ProcedureModel)
|
|
.where(ProcedureModel.id == existing.id)
|
|
.values(
|
|
steps=merged_steps,
|
|
trigger_pattern=new_trigger,
|
|
success_count=ProcedureModel.success_count + 1,
|
|
last_used=now,
|
|
updated_at=now,
|
|
)
|
|
.returning(ProcedureModel)
|
|
)
|
|
|
|
result = await self._session.execute(stmt)
|
|
updated_model = result.scalar_one()
|
|
await self._session.flush()
|
|
|
|
logger.info(f"Updated existing procedure: {existing.name}")
|
|
|
|
return _model_to_procedure(updated_model)
|
|
|
|
def _merge_steps(
|
|
self,
|
|
existing_steps: list[dict[str, Any]],
|
|
new_steps: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
"""Merge steps from a new execution with existing steps."""
|
|
if not existing_steps:
|
|
return new_steps
|
|
if not new_steps:
|
|
return existing_steps
|
|
|
|
# For now, use the new steps if they differ significantly
|
|
# In production, this could use more sophisticated merging
|
|
if len(new_steps) != len(existing_steps):
|
|
# If structure changed, prefer newer steps
|
|
return new_steps
|
|
|
|
# Merge step-by-step, preferring new data where available
|
|
merged = []
|
|
for i, new_step in enumerate(new_steps):
|
|
if i < len(existing_steps):
|
|
# Merge with existing step
|
|
step = {**existing_steps[i], **new_step}
|
|
else:
|
|
step = new_step
|
|
merged.append(step)
|
|
|
|
return merged
|
|
|
|
def _create_embedding_text(self, procedure: ProcedureCreate) -> str:
|
|
"""Create text for embedding from procedure data."""
|
|
steps_text = " ".join(step.get("action", "") for step in procedure.steps)
|
|
return f"{procedure.name} {procedure.trigger_pattern} {steps_text}"
|
|
|
|
# =========================================================================
|
|
# Procedure Retrieval
|
|
# =========================================================================
|
|
|
|
async def find_matching(
|
|
self,
|
|
context: str,
|
|
project_id: UUID | None = None,
|
|
agent_type_id: UUID | None = None,
|
|
limit: int = 5,
|
|
) -> list[Procedure]:
|
|
"""
|
|
Find procedures matching the given context.
|
|
|
|
Args:
|
|
context: Context/trigger to match against
|
|
project_id: Optional project to search within
|
|
agent_type_id: Optional agent type filter
|
|
limit: Maximum results
|
|
|
|
Returns:
|
|
List of matching procedures
|
|
"""
|
|
result = await self._find_matching_with_metadata(
|
|
context=context,
|
|
project_id=project_id,
|
|
agent_type_id=agent_type_id,
|
|
limit=limit,
|
|
)
|
|
return result.items
|
|
|
|
async def _find_matching_with_metadata(
|
|
self,
|
|
context: str,
|
|
project_id: UUID | None = None,
|
|
agent_type_id: UUID | None = None,
|
|
limit: int = 5,
|
|
) -> RetrievalResult[Procedure]:
|
|
"""Find matching procedures with full result metadata."""
|
|
start_time = time.perf_counter()
|
|
|
|
# Build base query - prioritize by success rate
|
|
stmt = (
|
|
select(ProcedureModel)
|
|
.order_by(
|
|
desc(
|
|
ProcedureModel.success_count
|
|
/ (ProcedureModel.success_count + ProcedureModel.failure_count + 1)
|
|
),
|
|
desc(ProcedureModel.last_used),
|
|
)
|
|
.limit(limit)
|
|
)
|
|
|
|
# Apply scope filters
|
|
if project_id is not None:
|
|
stmt = stmt.where(
|
|
or_(
|
|
ProcedureModel.project_id == project_id,
|
|
ProcedureModel.project_id.is_(None),
|
|
)
|
|
)
|
|
|
|
if agent_type_id is not None:
|
|
stmt = stmt.where(
|
|
or_(
|
|
ProcedureModel.agent_type_id == agent_type_id,
|
|
ProcedureModel.agent_type_id.is_(None),
|
|
)
|
|
)
|
|
|
|
# Text-based matching on trigger pattern and name
|
|
# TODO: Implement proper vector similarity search when pgvector is integrated
|
|
search_terms = context.lower().split()[:5] # Limit to 5 terms
|
|
if search_terms:
|
|
conditions = []
|
|
for term in search_terms:
|
|
# Escape SQL wildcards to prevent pattern injection
|
|
escaped_term = _escape_like_pattern(term)
|
|
term_pattern = f"%{escaped_term}%"
|
|
conditions.append(
|
|
or_(
|
|
ProcedureModel.trigger_pattern.ilike(term_pattern),
|
|
ProcedureModel.name.ilike(term_pattern),
|
|
)
|
|
)
|
|
if conditions:
|
|
stmt = stmt.where(or_(*conditions))
|
|
|
|
result = await self._session.execute(stmt)
|
|
models = list(result.scalars().all())
|
|
|
|
latency_ms = (time.perf_counter() - start_time) * 1000
|
|
|
|
return RetrievalResult(
|
|
items=[_model_to_procedure(m) for m in models],
|
|
total_count=len(models),
|
|
query=context,
|
|
retrieval_type="procedural",
|
|
latency_ms=latency_ms,
|
|
metadata={"project_id": str(project_id) if project_id else None},
|
|
)
|
|
|
|
async def get_best_procedure(
|
|
self,
|
|
task_type: str,
|
|
project_id: UUID | None = None,
|
|
agent_type_id: UUID | None = None,
|
|
min_success_rate: float = 0.5,
|
|
min_uses: int = 1,
|
|
) -> Procedure | None:
|
|
"""
|
|
Get the best procedure for a given task type.
|
|
|
|
Returns the procedure with the highest success rate that
|
|
meets the minimum thresholds.
|
|
|
|
Args:
|
|
task_type: Task type to find procedure for
|
|
project_id: Optional project scope
|
|
agent_type_id: Optional agent type scope
|
|
min_success_rate: Minimum required success rate
|
|
min_uses: Minimum number of uses required
|
|
|
|
Returns:
|
|
Best matching procedure or None
|
|
"""
|
|
# Escape SQL wildcards to prevent pattern injection
|
|
escaped_task_type = _escape_like_pattern(task_type)
|
|
task_type_pattern = f"%{escaped_task_type}%"
|
|
|
|
# Build query for procedures matching task type
|
|
stmt = (
|
|
select(ProcedureModel)
|
|
.where(
|
|
and_(
|
|
(ProcedureModel.success_count + ProcedureModel.failure_count)
|
|
>= min_uses,
|
|
or_(
|
|
ProcedureModel.trigger_pattern.ilike(task_type_pattern),
|
|
ProcedureModel.name.ilike(task_type_pattern),
|
|
),
|
|
)
|
|
)
|
|
.order_by(
|
|
desc(
|
|
ProcedureModel.success_count
|
|
/ (ProcedureModel.success_count + ProcedureModel.failure_count + 1)
|
|
),
|
|
desc(ProcedureModel.last_used),
|
|
)
|
|
.limit(10)
|
|
)
|
|
|
|
# Apply scope filters
|
|
if project_id is not None:
|
|
stmt = stmt.where(
|
|
or_(
|
|
ProcedureModel.project_id == project_id,
|
|
ProcedureModel.project_id.is_(None),
|
|
)
|
|
)
|
|
|
|
if agent_type_id is not None:
|
|
stmt = stmt.where(
|
|
or_(
|
|
ProcedureModel.agent_type_id == agent_type_id,
|
|
ProcedureModel.agent_type_id.is_(None),
|
|
)
|
|
)
|
|
|
|
result = await self._session.execute(stmt)
|
|
models = list(result.scalars().all())
|
|
|
|
# Filter by success rate in Python (SQLAlchemy division in WHERE is complex)
|
|
for model in models:
|
|
success = float(model.success_count)
|
|
failure = float(model.failure_count)
|
|
total = success + failure
|
|
if total > 0 and (success / total) >= min_success_rate:
|
|
logger.debug(
|
|
f"Found best procedure for '{task_type}': {model.name} "
|
|
f"(success_rate={success / total:.2%})"
|
|
)
|
|
return _model_to_procedure(model)
|
|
|
|
return None
|
|
|
|
async def get_by_id(self, procedure_id: UUID) -> Procedure | None:
|
|
"""Get a procedure by ID."""
|
|
query = select(ProcedureModel).where(ProcedureModel.id == procedure_id)
|
|
result = await self._session.execute(query)
|
|
model = result.scalar_one_or_none()
|
|
return _model_to_procedure(model) if model else None
|
|
|
|
# =========================================================================
|
|
# Outcome Recording
|
|
# =========================================================================
|
|
|
|
async def record_outcome(
|
|
self,
|
|
procedure_id: UUID,
|
|
success: bool,
|
|
) -> Procedure:
|
|
"""
|
|
Record the outcome of using a procedure.
|
|
|
|
Updates the success or failure count and last_used timestamp.
|
|
|
|
Args:
|
|
procedure_id: Procedure that was used
|
|
success: Whether the procedure succeeded
|
|
|
|
Returns:
|
|
Updated procedure
|
|
|
|
Raises:
|
|
ValueError: If procedure not found
|
|
"""
|
|
query = select(ProcedureModel).where(ProcedureModel.id == procedure_id)
|
|
result = await self._session.execute(query)
|
|
model = result.scalar_one_or_none()
|
|
|
|
if model is None:
|
|
raise ValueError(f"Procedure not found: {procedure_id}")
|
|
|
|
now = datetime.now(UTC)
|
|
|
|
if success:
|
|
stmt = (
|
|
update(ProcedureModel)
|
|
.where(ProcedureModel.id == procedure_id)
|
|
.values(
|
|
success_count=ProcedureModel.success_count + 1,
|
|
last_used=now,
|
|
updated_at=now,
|
|
)
|
|
.returning(ProcedureModel)
|
|
)
|
|
else:
|
|
stmt = (
|
|
update(ProcedureModel)
|
|
.where(ProcedureModel.id == procedure_id)
|
|
.values(
|
|
failure_count=ProcedureModel.failure_count + 1,
|
|
last_used=now,
|
|
updated_at=now,
|
|
)
|
|
.returning(ProcedureModel)
|
|
)
|
|
|
|
result = await self._session.execute(stmt)
|
|
updated_model = result.scalar_one()
|
|
await self._session.flush()
|
|
|
|
outcome = "success" if success else "failure"
|
|
logger.info(
|
|
f"Recorded {outcome} for procedure {procedure_id}: "
|
|
f"success_rate={updated_model.success_rate:.2%}"
|
|
)
|
|
|
|
return _model_to_procedure(updated_model)
|
|
|
|
# =========================================================================
|
|
# Step Management
|
|
# =========================================================================
|
|
|
|
async def update_steps(
|
|
self,
|
|
procedure_id: UUID,
|
|
steps: list[Step],
|
|
) -> Procedure:
|
|
"""
|
|
Update the steps of a procedure.
|
|
|
|
Args:
|
|
procedure_id: Procedure to update
|
|
steps: New steps
|
|
|
|
Returns:
|
|
Updated procedure
|
|
|
|
Raises:
|
|
ValueError: If procedure not found
|
|
"""
|
|
query = select(ProcedureModel).where(ProcedureModel.id == procedure_id)
|
|
result = await self._session.execute(query)
|
|
model = result.scalar_one_or_none()
|
|
|
|
if model is None:
|
|
raise ValueError(f"Procedure not found: {procedure_id}")
|
|
|
|
# Convert Step objects to dictionaries
|
|
steps_dict = [
|
|
{
|
|
"order": step.order,
|
|
"action": step.action,
|
|
"parameters": step.parameters,
|
|
"expected_outcome": step.expected_outcome,
|
|
"fallback_action": step.fallback_action,
|
|
}
|
|
for step in steps
|
|
]
|
|
|
|
now = datetime.now(UTC)
|
|
stmt = (
|
|
update(ProcedureModel)
|
|
.where(ProcedureModel.id == procedure_id)
|
|
.values(
|
|
steps=steps_dict,
|
|
updated_at=now,
|
|
)
|
|
.returning(ProcedureModel)
|
|
)
|
|
|
|
result = await self._session.execute(stmt)
|
|
updated_model = result.scalar_one()
|
|
await self._session.flush()
|
|
|
|
logger.info(f"Updated steps for procedure {procedure_id}: {len(steps)} steps")
|
|
|
|
return _model_to_procedure(updated_model)
|
|
|
|
# =========================================================================
|
|
# Statistics & Management
|
|
# =========================================================================
|
|
|
|
async def get_stats(
|
|
self,
|
|
project_id: UUID | None = None,
|
|
agent_type_id: UUID | None = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Get statistics about procedural memory.
|
|
|
|
Args:
|
|
project_id: Optional project to get stats for
|
|
agent_type_id: Optional agent type filter
|
|
|
|
Returns:
|
|
Dictionary with statistics
|
|
"""
|
|
query = select(ProcedureModel)
|
|
|
|
if project_id is not None:
|
|
query = query.where(
|
|
or_(
|
|
ProcedureModel.project_id == project_id,
|
|
ProcedureModel.project_id.is_(None),
|
|
)
|
|
)
|
|
|
|
if agent_type_id is not None:
|
|
query = query.where(
|
|
or_(
|
|
ProcedureModel.agent_type_id == agent_type_id,
|
|
ProcedureModel.agent_type_id.is_(None),
|
|
)
|
|
)
|
|
|
|
result = await self._session.execute(query)
|
|
models = list(result.scalars().all())
|
|
|
|
if not models:
|
|
return {
|
|
"total_procedures": 0,
|
|
"avg_success_rate": 0.0,
|
|
"avg_steps_count": 0.0,
|
|
"total_uses": 0,
|
|
"high_success_count": 0,
|
|
"low_success_count": 0,
|
|
}
|
|
|
|
success_rates = [m.success_rate for m in models]
|
|
step_counts = [len(m.steps or []) for m in models]
|
|
total_uses = sum(m.total_uses for m in models)
|
|
|
|
return {
|
|
"total_procedures": len(models),
|
|
"avg_success_rate": sum(success_rates) / len(success_rates),
|
|
"avg_steps_count": sum(step_counts) / len(step_counts),
|
|
"total_uses": total_uses,
|
|
"high_success_count": sum(1 for r in success_rates if r >= 0.8),
|
|
"low_success_count": sum(1 for r in success_rates if r < 0.5),
|
|
}
|
|
|
|
async def count(
|
|
self,
|
|
project_id: UUID | None = None,
|
|
agent_type_id: UUID | None = None,
|
|
) -> int:
|
|
"""
|
|
Count procedures in scope.
|
|
|
|
Args:
|
|
project_id: Optional project to count for
|
|
agent_type_id: Optional agent type filter
|
|
|
|
Returns:
|
|
Number of procedures
|
|
"""
|
|
query = select(ProcedureModel)
|
|
|
|
if project_id is not None:
|
|
query = query.where(
|
|
or_(
|
|
ProcedureModel.project_id == project_id,
|
|
ProcedureModel.project_id.is_(None),
|
|
)
|
|
)
|
|
|
|
if agent_type_id is not None:
|
|
query = query.where(
|
|
or_(
|
|
ProcedureModel.agent_type_id == agent_type_id,
|
|
ProcedureModel.agent_type_id.is_(None),
|
|
)
|
|
)
|
|
|
|
result = await self._session.execute(query)
|
|
return len(list(result.scalars().all()))
|
|
|
|
async def delete(self, procedure_id: UUID) -> bool:
|
|
"""
|
|
Delete a procedure.
|
|
|
|
Args:
|
|
procedure_id: Procedure to delete
|
|
|
|
Returns:
|
|
True if deleted, False if not found
|
|
"""
|
|
query = select(ProcedureModel).where(ProcedureModel.id == procedure_id)
|
|
result = await self._session.execute(query)
|
|
model = result.scalar_one_or_none()
|
|
|
|
if model is None:
|
|
return False
|
|
|
|
await self._session.delete(model)
|
|
await self._session.flush()
|
|
|
|
logger.info(f"Deleted procedure {procedure_id}")
|
|
return True
|
|
|
|
async def get_procedures_by_success_rate(
|
|
self,
|
|
min_rate: float = 0.0,
|
|
max_rate: float = 1.0,
|
|
project_id: UUID | None = None,
|
|
limit: int = 20,
|
|
) -> list[Procedure]:
|
|
"""
|
|
Get procedures within a success rate range.
|
|
|
|
Args:
|
|
min_rate: Minimum success rate
|
|
max_rate: Maximum success rate
|
|
project_id: Optional project scope
|
|
limit: Maximum results
|
|
|
|
Returns:
|
|
List of procedures
|
|
"""
|
|
query = (
|
|
select(ProcedureModel)
|
|
.order_by(desc(ProcedureModel.last_used))
|
|
.limit(limit * 2) # Fetch more since we filter in Python
|
|
)
|
|
|
|
if project_id is not None:
|
|
query = query.where(
|
|
or_(
|
|
ProcedureModel.project_id == project_id,
|
|
ProcedureModel.project_id.is_(None),
|
|
)
|
|
)
|
|
|
|
result = await self._session.execute(query)
|
|
models = list(result.scalars().all())
|
|
|
|
# Filter by success rate in Python
|
|
filtered = [m for m in models if min_rate <= m.success_rate <= max_rate][:limit]
|
|
|
|
return [_model_to_procedure(m) for m in filtered]
|