315 lines
9.4 KiB
Python
315 lines
9.4 KiB
Python
"""LangChain-based mock AI provider for testing purposes only.
|
|
|
|
This mock implementation uses LangChain's FakeListLLM for deterministic testing,
|
|
following the mock placement rule that all mocks must exist in the features/
|
|
directory, never in production code (ADR-022).
|
|
"""
|
|
|
|
import os
|
|
from collections.abc import Callable, Iterator
|
|
from typing import Any
|
|
|
|
from langchain_community.llms import FakeListLLM
|
|
from langchain_core.language_models import BaseLLM
|
|
from langchain_core.output_parsers import StrOutputParser
|
|
from langchain_core.prompts import PromptTemplate
|
|
|
|
from cleveragents.domain.models.core import (
|
|
Change,
|
|
Context,
|
|
OperationType,
|
|
Plan,
|
|
Project,
|
|
)
|
|
from cleveragents.domain.providers.ai_provider import (
|
|
ActorInvocationContext,
|
|
ProviderResponse,
|
|
)
|
|
|
|
|
|
class FailingLLM(BaseLLM):
|
|
"""A LangChain LLM that always fails for testing error handling."""
|
|
|
|
@property
|
|
def _llm_type(self) -> str:
|
|
return "failing"
|
|
|
|
def _call(
|
|
self,
|
|
prompt: str,
|
|
stop: list[str] | None = None,
|
|
run_manager: Any = None,
|
|
**kwargs: Any,
|
|
) -> str:
|
|
"""Always raise an exception."""
|
|
raise Exception("Mock AI provider configured to fail")
|
|
|
|
async def _acall(
|
|
self,
|
|
prompt: str,
|
|
stop: list[str] | None = None,
|
|
run_manager: Any = None,
|
|
**kwargs: Any,
|
|
) -> str:
|
|
"""Always raise an exception."""
|
|
raise Exception("Mock AI provider configured to fail")
|
|
|
|
|
|
def get_mock_llm() -> BaseLLM:
|
|
"""Get the appropriate mock LLM based on environment variables.
|
|
|
|
Returns:
|
|
FailingLLM if CLEVERAGENTS_MOCK_SHOULD_FAIL is set,
|
|
otherwise a standard FakeListLLM for testing.
|
|
"""
|
|
if os.environ.get("CLEVERAGENTS_MOCK_SHOULD_FAIL") == "true":
|
|
return FailingLLM()
|
|
|
|
# Return standard mock with deterministic responses
|
|
return FakeListLLM(
|
|
responses=[
|
|
"Requirements: Add error handling with try-except blocks",
|
|
"Generated code with proper error handling implementation",
|
|
"Validation passed: Code follows best practices",
|
|
]
|
|
)
|
|
|
|
|
|
class LangChainMockProvider:
|
|
"""LangChain-based mock AI provider that generates deterministic test responses.
|
|
|
|
This provider uses LangChain's FakeListLLM for deterministic testing,
|
|
avoiding calls to real AI APIs during tests.
|
|
"""
|
|
|
|
def __init__(self, model_id: str = "langchain-mock-gpt-4"):
|
|
"""Initialize the LangChain mock provider.
|
|
|
|
Args:
|
|
model_id: Mock model identifier
|
|
"""
|
|
self._model_id = model_id
|
|
self._name = "LangChainMockProvider"
|
|
|
|
# Define deterministic responses for different prompt patterns
|
|
self._responses = [
|
|
"""# Error handling code
|
|
def handle_error(error):
|
|
\"\"\"Handle errors gracefully.\"\"\"
|
|
import logging
|
|
logging.error(f"Error occurred: {error}")
|
|
return None
|
|
""",
|
|
"""# Test file
|
|
import pytest
|
|
|
|
def test_example():
|
|
\"\"\"Test example functionality.\"\"\"
|
|
assert True
|
|
|
|
def test_error_handling():
|
|
\"\"\"Test error handling.\"\"\"
|
|
with pytest.raises(ValueError):
|
|
raise ValueError("Test error")
|
|
""",
|
|
"""# Refactored code
|
|
class RefactoredClass:
|
|
\"\"\"Improved implementation.\"\"\"
|
|
|
|
def __init__(self):
|
|
self.state = "initialized"
|
|
|
|
def process(self, data):
|
|
\"\"\"Process data with better error handling.\"\"\"
|
|
if not data:
|
|
return None
|
|
return f"Processed: {data}"
|
|
""",
|
|
"""# Generated code
|
|
def main():
|
|
\"\"\"Main entry point.\"\"\"
|
|
print("Hello from CleverAgents!")
|
|
return 0
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
sys.exit(main())
|
|
""",
|
|
]
|
|
|
|
# Initialize FakeListLLM with responses
|
|
self._llm = FakeListLLM(responses=self._responses)
|
|
|
|
# Create a simple prompt template
|
|
self._prompt_template = PromptTemplate(
|
|
input_variables=["instruction", "context"],
|
|
template="""
|
|
You are a code generation assistant. Generate code based on the following:
|
|
|
|
Instruction: {instruction}
|
|
|
|
Context Files:
|
|
{context}
|
|
|
|
Generated Code:
|
|
""",
|
|
)
|
|
|
|
# Create chain
|
|
self._chain = self._prompt_template | self._llm | StrOutputParser()
|
|
|
|
def generate_changes(
|
|
self,
|
|
project: Project,
|
|
plan: Plan,
|
|
contexts: list[Context],
|
|
actor_context: ActorInvocationContext | None = None,
|
|
progress_callback: Callable[[int], None] | None = None,
|
|
) -> ProviderResponse:
|
|
"""Generate mock code changes based on the plan using LangChain.
|
|
|
|
Args:
|
|
project: The project being modified
|
|
plan: The plan containing instructions
|
|
contexts: List of context files to consider
|
|
progress_callback: Optional callback for progress updates (0-100)
|
|
|
|
Returns:
|
|
ProviderResponse containing mock changes
|
|
"""
|
|
# Check if we should fail for testing
|
|
import os
|
|
|
|
if os.environ.get("CLEVERAGENTS_MOCK_SHOULD_FAIL") == "true":
|
|
raise Exception("Mock AI provider configured to fail")
|
|
|
|
# Check if we should generate invalid code for testing
|
|
if os.environ.get("CLEVERAGENTS_MOCK_INVALID_CODE") == "true":
|
|
# Generate syntactically invalid Python code
|
|
generated_code = (
|
|
"def invalid_function(\n # Missing closing paren and body"
|
|
)
|
|
prompt_lower = ""
|
|
else:
|
|
# Simulate progress
|
|
if progress_callback:
|
|
for i in range(0, 101, 20):
|
|
progress_callback(i)
|
|
|
|
# Prepare context for the prompt
|
|
context_str = ""
|
|
if contexts:
|
|
context_str = "\n".join(
|
|
f"File: {ctx.path}\nContent: {ctx.content[:200]}..."
|
|
for ctx in contexts[:3] # Limit to first 3 for brevity
|
|
)
|
|
|
|
# Use LangChain to generate response
|
|
prompt_lower = plan.prompt.lower() if plan.prompt else ""
|
|
|
|
try:
|
|
# Invoke the chain with the prompt
|
|
generated_code = self._chain.invoke(
|
|
{
|
|
"instruction": plan.prompt or "Generate example code",
|
|
"context": context_str or "No context files provided",
|
|
}
|
|
)
|
|
except Exception:
|
|
# Fallback to default if chain fails
|
|
generated_code = (
|
|
"# Default generated code\nprint('Hello from CleverAgents!')\n"
|
|
)
|
|
|
|
# Determine file path and operation based on prompt
|
|
if "error handling" in prompt_lower or "exception" in prompt_lower:
|
|
file_path = "error_handler.py"
|
|
operation = OperationType.CREATE
|
|
elif "test" in prompt_lower:
|
|
file_path = "test_example.py"
|
|
operation = OperationType.CREATE
|
|
elif "refactor" in prompt_lower or "improve" in prompt_lower:
|
|
if contexts and len(contexts) > 0:
|
|
file_path = contexts[0].path
|
|
operation = OperationType.MODIFY
|
|
else:
|
|
file_path = "refactored.py"
|
|
operation = OperationType.CREATE
|
|
else:
|
|
file_path = "generated.py"
|
|
operation = OperationType.CREATE
|
|
|
|
# Create change object
|
|
plan_id = plan.id if plan.id else 0 # Use 0 as placeholder for tests
|
|
|
|
changes = [
|
|
Change(
|
|
id=None,
|
|
plan_id=plan_id,
|
|
file_path=file_path,
|
|
operation=operation,
|
|
original_content=contexts[0].content
|
|
if contexts and operation == OperationType.MODIFY
|
|
else None,
|
|
new_content=generated_code,
|
|
applied=False,
|
|
applied_at=None,
|
|
new_path=None,
|
|
)
|
|
]
|
|
|
|
# Calculate approximate token count (rough estimate)
|
|
token_count = len(generated_code.split()) * 1.3 # Rough approximation
|
|
|
|
return ProviderResponse(
|
|
changes=changes,
|
|
model_used=self._model_id,
|
|
token_count=int(token_count),
|
|
error_message=None,
|
|
)
|
|
|
|
def stream_changes(
|
|
self,
|
|
project: Project,
|
|
plan: Plan,
|
|
contexts: list[Context],
|
|
actor_context: ActorInvocationContext | None = None,
|
|
progress_callback: Callable[[int], None] | None = None,
|
|
) -> Iterator[dict[str, object]]:
|
|
"""Stream mock workflow events for CLI coverage."""
|
|
|
|
response = self.generate_changes(
|
|
project,
|
|
plan,
|
|
contexts,
|
|
progress_callback=progress_callback,
|
|
)
|
|
|
|
for node in (
|
|
"load_context",
|
|
"analyze_requirements",
|
|
"generate_plan",
|
|
"validate",
|
|
):
|
|
payload: dict[str, object] = {}
|
|
if node == "generate_plan":
|
|
payload = {
|
|
"status": "completed",
|
|
"change_count": len(response.changes),
|
|
}
|
|
elif node == "validate":
|
|
payload = {"status": "completed"}
|
|
yield {node: payload}
|
|
|
|
yield {"__end__": {"response": response}}
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
"""Get the provider name."""
|
|
return self._name
|
|
|
|
@property
|
|
def model_id(self) -> str:
|
|
"""Get the model identifier."""
|
|
return self._model_id
|