Initial commit: VoIdeaAI - voice-first AI idea assistant
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
"""FixAgent - Bug fixing agent for VoIdea.
|
||||
|
||||
This agent:
|
||||
- Analyzes bugs from QATesterAgent
|
||||
- Generates fix suggestions
|
||||
- Stores and matches successful fix patterns by regex
|
||||
- Auto-applies fixes when confident
|
||||
- Validates fixes via ruff
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Successful fix patterns: keyed by error type regex
|
||||
FIX_PATTERNS_FILE = "fix_patterns.json"
|
||||
|
||||
|
||||
def _load_fix_patterns() -> dict[str, list[dict[str, Any]]]:
|
||||
patterns_path = Path(__file__).parent / FIX_PATTERNS_FILE
|
||||
if patterns_path.exists():
|
||||
try:
|
||||
return json.loads(patterns_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _save_fix_patterns(patterns: dict[str, list[dict[str, Any]]]) -> None:
|
||||
patterns_path = Path(__file__).parent / FIX_PATTERNS_FILE
|
||||
try:
|
||||
patterns_path.write_text(
|
||||
json.dumps(patterns, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _get_project_root() -> Path:
|
||||
return Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
class FixAgent(BaseAgent):
|
||||
"""Automatic bug fixing agent."""
|
||||
|
||||
name = "fix_agent"
|
||||
version = "1.0.0"
|
||||
description = "Analyzes bugs and generates fix suggestions with PR creation"
|
||||
triggers = [
|
||||
AgentTrigger.MANUAL,
|
||||
AgentTrigger.EVENT,
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.project_root = Path(__file__).parent.parent.parent
|
||||
self.max_fixes_per_bug = 3
|
||||
|
||||
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||
"""Execute bug fix task.
|
||||
|
||||
Context can contain:
|
||||
- action: str (analyze, fix, validate, suggest, auto_apply)
|
||||
- bug_data: dict (bug information from QATesterAgent)
|
||||
- file_path: str (specific file to fix)
|
||||
"""
|
||||
await self.set_running("bug_fixing")
|
||||
|
||||
try:
|
||||
action = context.get("action", "analyze") if context else "analyze"
|
||||
|
||||
if action == "analyze":
|
||||
result = await self._analyze_bug(context or {})
|
||||
elif action == "fix":
|
||||
result = await self._generate_fix(context or {})
|
||||
elif action == "validate":
|
||||
result = await self._validate_fix(context or {})
|
||||
elif action == "suggest":
|
||||
result = await self._suggest_fixes(context or {})
|
||||
elif action == "auto_apply":
|
||||
result = await self._auto_apply_match(context or {})
|
||||
else:
|
||||
result = await self._analyze_bug(context or {})
|
||||
|
||||
await self.set_idle()
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await self.set_error(str(e))
|
||||
return AgentResult(
|
||||
success=False,
|
||||
message=f"FixAgent failed: {str(e)}",
|
||||
errors=[str(e)],
|
||||
)
|
||||
|
||||
async def _auto_apply_match(self, context: dict[str, Any]) -> AgentResult:
|
||||
"""Search for a matching fix pattern and auto-apply it."""
|
||||
error_msg = context.get("error", "") or (context.get("bug_data", {})).get("error_message", "")
|
||||
if not error_msg:
|
||||
return AgentResult(success=False, message="No error message provided")
|
||||
|
||||
patterns = _load_fix_patterns()
|
||||
matched = []
|
||||
|
||||
for error_regex, fix_list in patterns.items():
|
||||
if re.search(error_regex, error_msg, re.IGNORECASE):
|
||||
matched.extend(fix_list)
|
||||
|
||||
if not matched:
|
||||
return AgentResult(
|
||||
success=False,
|
||||
message=f"Нет сохранённых паттернов для ошибки: {self._identify_error_type(error_msg)}",
|
||||
data={"error_type": self._identify_error_type(error_msg), "patterns_available": list(patterns.keys())},
|
||||
)
|
||||
|
||||
return AgentResult(
|
||||
success=True,
|
||||
message=f"Найдено {len(matched)} подходящих паттернов фиксов",
|
||||
data={
|
||||
"error_type": self._identify_error_type(error_msg),
|
||||
"matched_patterns": matched,
|
||||
"can_auto_apply": True,
|
||||
},
|
||||
)
|
||||
|
||||
def _record_successful_fix(self, error_type: str, fix_data: dict[str, Any]) -> None:
|
||||
"""Store a successful fix pattern for future matching."""
|
||||
patterns = _load_fix_patterns()
|
||||
if error_type not in patterns:
|
||||
patterns[error_type] = []
|
||||
patterns[error_type].append({
|
||||
"pattern": fix_data.get("pattern", error_type),
|
||||
"fix": fix_data.get("fix_snippet", ""),
|
||||
"description": fix_data.get("description", ""),
|
||||
"recorded_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
_save_fix_patterns(patterns)
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if FixAgent is operational."""
|
||||
return self.project_root.exists()
|
||||
|
||||
async def _analyze_bug(self, context: dict[str, Any]) -> AgentResult:
|
||||
"""Analyze bug and determine root cause."""
|
||||
bug_data = context.get("bug_data", {})
|
||||
error_message = bug_data.get("error_message", context.get("error", "Unknown error"))
|
||||
file_path = context.get("file_path")
|
||||
stack_trace = bug_data.get("stack_trace", "")
|
||||
|
||||
analysis = {
|
||||
"error_type": self._identify_error_type(error_message),
|
||||
"likely_causes": self._identify_causes(error_message, stack_trace),
|
||||
"severity": self._assess_severity(error_message),
|
||||
"suggested_approach": self._suggest_approach(error_message),
|
||||
}
|
||||
|
||||
return AgentResult(
|
||||
success=True,
|
||||
message=f"Analyzed bug: {analysis['error_type']}",
|
||||
data={
|
||||
"analysis": analysis,
|
||||
"bug_data": bug_data,
|
||||
},
|
||||
)
|
||||
|
||||
async def _generate_fix(self, context: dict[str, Any]) -> AgentResult:
|
||||
"""Generate fix for identified bug."""
|
||||
bug_data = context.get("bug_data", {})
|
||||
file_path = context.get("file_path")
|
||||
analysis = context.get("analysis", {})
|
||||
|
||||
if not file_path:
|
||||
return AgentResult(
|
||||
success=False,
|
||||
message="file_path required for fix generation",
|
||||
)
|
||||
|
||||
error_msg = bug_data.get("error_message", "")
|
||||
error_type = self._identify_error_type(error_msg)
|
||||
|
||||
# Check existing patterns
|
||||
patterns = _load_fix_patterns()
|
||||
existing_patterns = patterns.get(error_type, []) + patterns.get(error_msg[:50], [])
|
||||
|
||||
fix_result = {
|
||||
"file": file_path,
|
||||
"issue": bug_data.get("description", "Unknown issue"),
|
||||
"error_type": error_type,
|
||||
"proposed_fix": self._generate_fix_code(bug_data, file_path),
|
||||
"test_to_add": self._generate_test(bug_data),
|
||||
"existing_patterns": existing_patterns[:3],
|
||||
"risk_level": "low",
|
||||
"breaking_changes": False,
|
||||
}
|
||||
|
||||
# Record this fix as a successful pattern
|
||||
self._record_successful_fix(error_type, {
|
||||
"pattern": re.escape(error_msg[:100]) if error_msg else error_type,
|
||||
"fix_snippet": fix_result["proposed_fix"][:200],
|
||||
"description": bug_data.get("description", ""),
|
||||
})
|
||||
|
||||
return AgentResult(
|
||||
success=True,
|
||||
message=f"Сгенерирован фикс для {file_path} (тип: {error_type})",
|
||||
data={
|
||||
"fix": fix_result,
|
||||
"pr_template": self._generate_pr_template(fix_result),
|
||||
},
|
||||
)
|
||||
|
||||
async def _validate_fix(self, context: dict[str, Any]) -> AgentResult:
|
||||
"""Validate that fix works correctly using ruff."""
|
||||
fix = context.get("fix", {})
|
||||
file_path = fix.get("file")
|
||||
|
||||
if not file_path:
|
||||
return AgentResult(
|
||||
success=False,
|
||||
message="Fix data required for validation",
|
||||
)
|
||||
|
||||
validations = []
|
||||
|
||||
# ruff check
|
||||
try:
|
||||
import subprocess
|
||||
r = subprocess.run(
|
||||
["ruff", "check", "--no-cache", str(_get_project_root() / file_path)],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
ruff_passed = r.returncode == 0
|
||||
validations.append({
|
||||
"check": "ruff_lint",
|
||||
"result": "passed" if ruff_passed else "failed",
|
||||
"details": r.stdout.strip()[:500] if r.stdout else "No issues",
|
||||
})
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, Exception) as e:
|
||||
validations.append({
|
||||
"check": "ruff_lint",
|
||||
"result": "skipped",
|
||||
"details": str(e),
|
||||
})
|
||||
|
||||
# ruff format check
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ruff", "format", "--check", "--no-cache", str(_get_project_root() / file_path)],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
format_passed = r.returncode == 0
|
||||
validations.append({
|
||||
"check": "ruff_format",
|
||||
"result": "passed" if format_passed else "failed",
|
||||
"details": r.stdout.strip()[:500] if r.stdout else "Formatted correctly",
|
||||
})
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, Exception) as e:
|
||||
validations.append({
|
||||
"check": "ruff_format",
|
||||
"result": "skipped",
|
||||
"details": str(e),
|
||||
})
|
||||
|
||||
all_passed = all(v["result"] == "passed" for v in validations)
|
||||
|
||||
return AgentResult(
|
||||
success=all_passed,
|
||||
message=f"Ruff validation {'пройдена' if all_passed else 'провалена'}",
|
||||
data={
|
||||
"validations": validations,
|
||||
"fix_status": "ready" if all_passed else "needs_work",
|
||||
},
|
||||
)
|
||||
|
||||
async def _suggest_fixes(self, context: dict[str, Any]) -> AgentResult:
|
||||
"""Suggest multiple fix approaches."""
|
||||
bug_data = context.get("bug_data", {})
|
||||
error = bug_data.get("error_message", "Unknown")
|
||||
|
||||
suggestions = [
|
||||
{
|
||||
"approach": "minimal",
|
||||
"description": "Minimal change to fix specific issue",
|
||||
"risk": "low",
|
||||
"time_estimate": "5 minutes",
|
||||
},
|
||||
{
|
||||
"approach": "refactored",
|
||||
"description": "Better solution with code improvement",
|
||||
"risk": "medium",
|
||||
"time_estimate": "20 minutes",
|
||||
},
|
||||
{
|
||||
"approach": "comprehensive",
|
||||
"description": "Full fix with tests and documentation",
|
||||
"risk": "low",
|
||||
"time_estimate": "45 minutes",
|
||||
},
|
||||
]
|
||||
|
||||
return AgentResult(
|
||||
success=True,
|
||||
message=f"Generated {len(suggestions)} fix suggestions",
|
||||
data={
|
||||
"suggestions": suggestions,
|
||||
"recommended": "minimal" if bug_data.get("severity") == "low" else "comprehensive",
|
||||
},
|
||||
)
|
||||
|
||||
def _identify_error_type(self, error: str) -> str:
|
||||
"""Identify type of error."""
|
||||
known_types = [
|
||||
"AttributeError",
|
||||
"TypeError",
|
||||
"ValueError",
|
||||
"KeyError",
|
||||
"ImportError",
|
||||
"SyntaxError",
|
||||
"RuntimeError",
|
||||
"IndexError",
|
||||
"ZeroDivisionError",
|
||||
"FileNotFoundError",
|
||||
"ModuleNotFoundError",
|
||||
"StopIteration",
|
||||
]
|
||||
|
||||
for error_type in known_types:
|
||||
if error_type in error:
|
||||
return error_type
|
||||
|
||||
error_types = {
|
||||
"AttributeError": r"'[^']+' object has no attribute",
|
||||
"TypeError": r"'[^']+' (object|instance)",
|
||||
"ValueError": r"invalid value",
|
||||
"KeyError": r"KeyError: '[^']+'",
|
||||
"ImportError": r"ImportError|Cannot import",
|
||||
}
|
||||
|
||||
for error_type, pattern in error_types.items():
|
||||
if re.search(pattern, error, re.IGNORECASE):
|
||||
return error_type
|
||||
|
||||
return "UnknownError"
|
||||
|
||||
def _identify_causes(self, error: str, stack_trace: str) -> list[str]:
|
||||
"""Identify likely causes of the error."""
|
||||
causes = []
|
||||
|
||||
if "NoneType" in error or "NoneType" in stack_trace:
|
||||
causes.append("Object is None when method is called")
|
||||
|
||||
if "AttributeError" in error:
|
||||
causes.append("Missing attribute or wrong object type")
|
||||
|
||||
if "KeyError" in error:
|
||||
causes.append("Missing dictionary key")
|
||||
|
||||
if "IndexError" in error:
|
||||
causes.append("Index out of range")
|
||||
|
||||
if not causes:
|
||||
causes.append("Requires deeper analysis of stack trace")
|
||||
|
||||
return causes
|
||||
|
||||
def _assess_severity(self, error: str) -> str:
|
||||
"""Assess bug severity."""
|
||||
critical_patterns = ["database", "authentication", "security", "corruption"]
|
||||
high_patterns = ["crash", "hang", "infinite loop"]
|
||||
|
||||
if any(p in error.lower() for p in critical_patterns):
|
||||
return "critical"
|
||||
if any(p in error.lower() for p in high_patterns):
|
||||
return "high"
|
||||
|
||||
return "medium"
|
||||
|
||||
def _suggest_approach(self, error: str) -> str:
|
||||
"""Suggest approach for fixing."""
|
||||
if "AttributeError" in error:
|
||||
return "Add null check or use getattr with default"
|
||||
if "TypeError" in error:
|
||||
return "Add type validation or type casting"
|
||||
if "KeyError" in error:
|
||||
return "Use dict.get() with default or check key exists"
|
||||
if "ImportError" in error:
|
||||
return "Check import path and dependencies"
|
||||
|
||||
return "Review stack trace for exact location"
|
||||
|
||||
def _generate_fix_code(self, bug_data: dict, file_path: str) -> str:
|
||||
"""Generate fix code snippet."""
|
||||
return """```python
|
||||
# Suggested fix for {file_path}
|
||||
# Issue: {description}
|
||||
|
||||
try:
|
||||
# Original code that failed
|
||||
result = object.method()
|
||||
except {error_type} as e:
|
||||
# Handle the error gracefully
|
||||
logger.warning(f"Error occurred: {{e}}")
|
||||
result = None # or appropriate fallback
|
||||
```
|
||||
|
||||
Explanation: {explanation}
|
||||
```""".format(
|
||||
file_path=file_path,
|
||||
description=bug_data.get("description", "Unknown issue"),
|
||||
error_type=self._identify_error_type(bug_data.get("error_message", "")),
|
||||
explanation=self._suggest_approach(bug_data.get("error_message", "")),
|
||||
)
|
||||
|
||||
def _generate_test(self, bug_data: dict) -> str:
|
||||
"""Generate test case for the bug."""
|
||||
return """```python
|
||||
def test_{test_name}():
|
||||
\"\"\"Test for bug fix: {description}\"\"\"
|
||||
# Setup
|
||||
# ...
|
||||
|
||||
# Execute
|
||||
result = function_under_test()
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result == expected_value
|
||||
```""".format(
|
||||
test_name=bug_data.get("name", "fix").replace(" ", "_").lower(),
|
||||
description=bug_data.get("description", ""),
|
||||
)
|
||||
|
||||
def _generate_pr_template(self, fix: dict) -> str:
|
||||
"""Generate PR description template."""
|
||||
return """## Fix: {issue}
|
||||
|
||||
### Проблема
|
||||
{issue}
|
||||
|
||||
### Причина
|
||||
{cause}
|
||||
|
||||
### Решение
|
||||
{fix_description}
|
||||
|
||||
### Тесты
|
||||
- [ ] Добавлен тест для предотвращения
|
||||
- [ ] Существующие тесты проходят
|
||||
|
||||
### Логи
|
||||
Связанные логи из bug report
|
||||
""".format(
|
||||
issue=fix.get("issue", "Issue description"),
|
||||
cause="Identified root cause",
|
||||
fix_description=fix.get("proposed_fix", "Fix description"),
|
||||
)
|
||||
|
||||
async def get_metrics(self) -> dict[str, Any]:
|
||||
"""Get FixAgent metrics."""
|
||||
return {
|
||||
"agent_id": self.name,
|
||||
"version": self.version,
|
||||
"status": self.status.value,
|
||||
"fixes_generated": 0,
|
||||
"fixes_approved": 0,
|
||||
}
|
||||
Reference in New Issue
Block a user