roma-debug 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
roma_debug/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ROMA Debug - Standalone CLI debugging tool."""
2
+
3
+ __version__ = "0.1.0"
roma_debug/config.py ADDED
@@ -0,0 +1,79 @@
1
+ """Centralized configuration for ROMA Debug.
2
+
3
+ This module is the single source of truth for all configuration.
4
+ It loads the .env file once at import time and exposes validated settings.
5
+ """
6
+
7
+ import os
8
+ from pathlib import Path
9
+
10
+ from dotenv import load_dotenv
11
+
12
+
13
+ def _find_project_root() -> Path:
14
+ """Find project root by searching for .env file.
15
+
16
+ Searches upward from this file's location until .env is found.
17
+ Falls back to the parent of roma_debug package.
18
+
19
+ Returns:
20
+ Path to project root directory
21
+ """
22
+ current = Path(__file__).resolve().parent
23
+
24
+ # Search upward for .env file (max 5 levels)
25
+ for _ in range(5):
26
+ if (current / ".env").exists():
27
+ return current
28
+ if current.parent == current:
29
+ break
30
+ current = current.parent
31
+
32
+ # Fallback: assume project root is parent of roma_debug/
33
+ return Path(__file__).resolve().parent.parent
34
+
35
+
36
+ def _load_config() -> str:
37
+ """Load configuration from .env file.
38
+
39
+ Returns:
40
+ The Gemini API key
41
+
42
+ Raises:
43
+ RuntimeError: If GEMINI_API_KEY is not set
44
+ """
45
+ project_root = _find_project_root()
46
+ env_path = project_root / ".env"
47
+
48
+ # Load .env file
49
+ if env_path.exists():
50
+ load_dotenv(env_path, override=True)
51
+ else:
52
+ # Try loading from environment anyway
53
+ load_dotenv()
54
+
55
+ # Get API key from environment
56
+ api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
57
+
58
+ if not api_key:
59
+ raise RuntimeError(
60
+ f"GEMINI_API_KEY not found!\n"
61
+ f"Searched .env at: {env_path}\n"
62
+ f"Please set GEMINI_API_KEY in your .env file or environment."
63
+ )
64
+
65
+ return api_key
66
+
67
+
68
+ # Load configuration at import time
69
+ # This ensures consistent behavior across CLI and server
70
+ GEMINI_API_KEY: str = _load_config()
71
+
72
+
73
+ def get_api_key_status() -> str:
74
+ """Get human-readable API key status for logging.
75
+
76
+ Returns:
77
+ 'OK' if key is loaded, 'MISSING' otherwise
78
+ """
79
+ return "OK" if GEMINI_API_KEY else "MISSING"
@@ -0,0 +1,5 @@
1
+ """Core modules for ROMA Debug."""
2
+
3
+ from roma_debug.core.engine import analyze_error
4
+
5
+ __all__ = ["analyze_error"]
@@ -0,0 +1,423 @@
1
+ """Gemini Fixer Logic for ROMA Debug.
2
+
3
+ Returns structured JSON responses for machine-readable fixes.
4
+ Supports both V1 (simple) and V2 (deep debugging) modes.
5
+ """
6
+
7
+ import json
8
+ import re
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from typing import Optional, List
12
+
13
+ from google import genai
14
+ from google.genai import types
15
+
16
+ from roma_debug.config import GEMINI_API_KEY
17
+ from roma_debug.prompts import SYSTEM_PROMPT, SYSTEM_PROMPT_V2
18
+
19
+
20
+ # Model priority: try Lite first (higher quota), then Flash
21
+ PRIMARY_MODEL = "gemini-2.5-flash-lite"
22
+ FALLBACK_MODEL = "gemini-2.5-flash"
23
+
24
+ # Placeholder paths that indicate the AI couldn't determine the real path
25
+ INVALID_PATHS = {
26
+ "unknown",
27
+ "path/to/file.py",
28
+ "path/to/your/code.py",
29
+ "path/to/your/file.py",
30
+ "example.py",
31
+ "your_file.py",
32
+ "file.py",
33
+ "",
34
+ }
35
+
36
+
37
+ @dataclass
38
+ class FixResult:
39
+ """Structured result from the AI engine."""
40
+ filepath: Optional[str] # None for general system errors
41
+ full_code_block: str
42
+ explanation: str
43
+ raw_response: str
44
+ model_used: str
45
+
46
+ def to_dict(self) -> dict:
47
+ """Convert to dictionary."""
48
+ return {
49
+ "filepath": self.filepath,
50
+ "full_code_block": self.full_code_block,
51
+ "explanation": self.explanation,
52
+ }
53
+
54
+
55
+ @dataclass
56
+ class AdditionalFix:
57
+ """An additional fix for another file."""
58
+ filepath: str
59
+ full_code_block: str
60
+ explanation: str
61
+
62
+
63
+ @dataclass
64
+ class FixResultV2(FixResult):
65
+ """V2 result with root cause analysis and multiple fixes."""
66
+ root_cause_file: Optional[str] = None
67
+ root_cause_explanation: Optional[str] = None
68
+ additional_fixes: List[AdditionalFix] = field(default_factory=list)
69
+
70
+ def to_dict(self) -> dict:
71
+ """Convert to dictionary."""
72
+ result = super().to_dict()
73
+ result["root_cause_file"] = self.root_cause_file
74
+ result["root_cause_explanation"] = self.root_cause_explanation
75
+ result["additional_fixes"] = [
76
+ {
77
+ "filepath": fix.filepath,
78
+ "full_code_block": fix.full_code_block,
79
+ "explanation": fix.explanation,
80
+ }
81
+ for fix in self.additional_fixes
82
+ ]
83
+ return result
84
+
85
+ @property
86
+ def has_root_cause(self) -> bool:
87
+ """Check if a root cause in a different file was identified."""
88
+ return bool(self.root_cause_file and self.root_cause_file != self.filepath)
89
+
90
+ @property
91
+ def all_files_to_fix(self) -> List[str]:
92
+ """Get list of all files that need fixes."""
93
+ files = []
94
+ if self.filepath:
95
+ files.append(self.filepath)
96
+ if self.root_cause_file and self.root_cause_file not in files:
97
+ files.append(self.root_cause_file)
98
+ for fix in self.additional_fixes:
99
+ if fix.filepath not in files:
100
+ files.append(fix.filepath)
101
+ return files
102
+
103
+
104
+ def _get_client() -> genai.Client:
105
+ """Get configured Gemini client."""
106
+ return genai.Client(api_key=GEMINI_API_KEY)
107
+
108
+
109
+ def _normalize_filepath(filepath: Optional[str]) -> Optional[str]:
110
+ """Normalize filepath, returning None for invalid/placeholder paths.
111
+
112
+ Args:
113
+ filepath: Raw filepath from AI response
114
+
115
+ Returns:
116
+ Validated filepath or None if invalid
117
+ """
118
+ if filepath is None:
119
+ return None
120
+
121
+ # Convert to string and strip whitespace
122
+ filepath = str(filepath).strip()
123
+
124
+ # Check against known invalid placeholders
125
+ if filepath.lower() in INVALID_PATHS:
126
+ return None
127
+
128
+ # Check for placeholder patterns
129
+ placeholder_patterns = [
130
+ r"^path/to/",
131
+ r"^your[_-]",
132
+ r"^example[_-]?",
133
+ r"<.*>", # <filename> style placeholders
134
+ ]
135
+ for pattern in placeholder_patterns:
136
+ if re.match(pattern, filepath, re.IGNORECASE):
137
+ return None
138
+
139
+ return filepath
140
+
141
+
142
+ def _parse_json_response(text: str) -> dict:
143
+ """Parse JSON from response, handling markdown code blocks.
144
+
145
+ Args:
146
+ text: Raw response text
147
+
148
+ Returns:
149
+ Parsed JSON dict
150
+
151
+ Raises:
152
+ ValueError: If JSON parsing fails
153
+ """
154
+ # Try direct JSON parse first
155
+ try:
156
+ return json.loads(text)
157
+ except json.JSONDecodeError:
158
+ pass
159
+
160
+ # Try extracting from markdown code block
161
+ json_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', text, re.DOTALL)
162
+ if json_match:
163
+ try:
164
+ return json.loads(json_match.group(1))
165
+ except json.JSONDecodeError:
166
+ pass
167
+
168
+ # Try finding JSON object in text
169
+ json_match = re.search(r'\{[\s\S]*\}', text)
170
+ if json_match:
171
+ try:
172
+ return json.loads(json_match.group(0))
173
+ except json.JSONDecodeError:
174
+ pass
175
+
176
+ raise ValueError(f"Could not parse JSON from response: {text[:200]}...")
177
+
178
+
179
+ def analyze_error(log: str, context: str, max_retries: int = 3) -> FixResult:
180
+ """Analyze an error log and return a structured code fix.
181
+
182
+ Args:
183
+ log: The error log or traceback
184
+ context: Source code context around the error
185
+ max_retries: Number of retries for rate limit errors
186
+
187
+ Returns:
188
+ FixResult with structured fix data (filepath may be None for general errors)
189
+
190
+ Raises:
191
+ Exception: If Gemini API call fails after retries
192
+ """
193
+ client = _get_client()
194
+
195
+ user_prompt = _build_prompt(log, context)
196
+ full_prompt = f"{SYSTEM_PROMPT}\n\n{user_prompt}"
197
+
198
+ # Configure for JSON output
199
+ generation_config = types.GenerateContentConfig(
200
+ temperature=0,
201
+ response_mime_type="application/json",
202
+ )
203
+
204
+ models_to_try = [PRIMARY_MODEL, FALLBACK_MODEL]
205
+ last_error = None
206
+
207
+ for model_name in models_to_try:
208
+ for attempt in range(max_retries):
209
+ try:
210
+ response = client.models.generate_content(
211
+ model=model_name,
212
+ contents=full_prompt,
213
+ config=generation_config,
214
+ )
215
+
216
+ raw_text = response.text
217
+
218
+ # Parse JSON response
219
+ try:
220
+ parsed = _parse_json_response(raw_text)
221
+ except ValueError:
222
+ # If JSON parsing fails, create a basic structure
223
+ parsed = {
224
+ "filepath": None,
225
+ "full_code_block": raw_text,
226
+ "explanation": "AI returned non-JSON response. Raw output provided.",
227
+ }
228
+
229
+ # Normalize and validate filepath
230
+ raw_filepath = parsed.get("filepath")
231
+ normalized_filepath = _normalize_filepath(raw_filepath)
232
+
233
+ return FixResult(
234
+ filepath=normalized_filepath,
235
+ full_code_block=parsed.get("full_code_block", ""),
236
+ explanation=parsed.get("explanation", ""),
237
+ raw_response=raw_text,
238
+ model_used=model_name,
239
+ )
240
+
241
+ except Exception as e:
242
+ error_str = str(e).lower()
243
+ last_error = e
244
+
245
+ is_quota_error = any(x in error_str for x in [
246
+ "429", "quota", "rate limit", "resource exhausted"
247
+ ])
248
+
249
+ if is_quota_error:
250
+ if model_name == PRIMARY_MODEL:
251
+ break # Try fallback model
252
+
253
+ wait_time = (2 ** attempt) * 5
254
+ if attempt < max_retries - 1:
255
+ time.sleep(wait_time)
256
+ continue
257
+
258
+ raise
259
+
260
+ if model_name == PRIMARY_MODEL and last_error:
261
+ continue
262
+
263
+ raise last_error
264
+
265
+
266
+ def analyze_error_v2(
267
+ log: str,
268
+ context: str,
269
+ max_retries: int = 3,
270
+ include_upstream: bool = True,
271
+ ) -> FixResultV2:
272
+ """Analyze an error with V2 deep debugging (root cause analysis).
273
+
274
+ Args:
275
+ log: The error log or traceback
276
+ context: Source code context (should include upstream context for V2)
277
+ max_retries: Number of retries for rate limit errors
278
+ include_upstream: Whether upstream context was included
279
+
280
+ Returns:
281
+ FixResultV2 with root cause analysis and potentially multiple fixes
282
+
283
+ Raises:
284
+ Exception: If Gemini API call fails after retries
285
+ """
286
+ client = _get_client()
287
+
288
+ user_prompt = _build_prompt_v2(log, context)
289
+ full_prompt = f"{SYSTEM_PROMPT_V2}\n\n{user_prompt}"
290
+
291
+ # Configure for JSON output
292
+ generation_config = types.GenerateContentConfig(
293
+ temperature=0,
294
+ response_mime_type="application/json",
295
+ )
296
+
297
+ models_to_try = [PRIMARY_MODEL, FALLBACK_MODEL]
298
+ last_error = None
299
+
300
+ for model_name in models_to_try:
301
+ for attempt in range(max_retries):
302
+ try:
303
+ response = client.models.generate_content(
304
+ model=model_name,
305
+ contents=full_prompt,
306
+ config=generation_config,
307
+ )
308
+
309
+ raw_text = response.text
310
+
311
+ # Parse JSON response
312
+ try:
313
+ parsed = _parse_json_response(raw_text)
314
+ except ValueError:
315
+ # If JSON parsing fails, create a basic structure
316
+ parsed = {
317
+ "filepath": None,
318
+ "full_code_block": raw_text,
319
+ "explanation": "AI returned non-JSON response. Raw output provided.",
320
+ }
321
+
322
+ # Normalize filepaths
323
+ normalized_filepath = _normalize_filepath(parsed.get("filepath"))
324
+ root_cause_file = _normalize_filepath(parsed.get("root_cause_file"))
325
+
326
+ # Parse additional fixes
327
+ additional_fixes = []
328
+ for fix_data in parsed.get("additional_fixes", []):
329
+ fix_path = _normalize_filepath(fix_data.get("filepath"))
330
+ if fix_path:
331
+ additional_fixes.append(AdditionalFix(
332
+ filepath=fix_path,
333
+ full_code_block=fix_data.get("full_code_block", ""),
334
+ explanation=fix_data.get("explanation", ""),
335
+ ))
336
+
337
+ return FixResultV2(
338
+ filepath=normalized_filepath,
339
+ full_code_block=parsed.get("full_code_block", ""),
340
+ explanation=parsed.get("explanation", ""),
341
+ raw_response=raw_text,
342
+ model_used=model_name,
343
+ root_cause_file=root_cause_file,
344
+ root_cause_explanation=parsed.get("root_cause_explanation"),
345
+ additional_fixes=additional_fixes,
346
+ )
347
+
348
+ except Exception as e:
349
+ error_str = str(e).lower()
350
+ last_error = e
351
+
352
+ is_quota_error = any(x in error_str for x in [
353
+ "429", "quota", "rate limit", "resource exhausted"
354
+ ])
355
+
356
+ if is_quota_error:
357
+ if model_name == PRIMARY_MODEL:
358
+ break # Try fallback model
359
+
360
+ wait_time = (2 ** attempt) * 5
361
+ if attempt < max_retries - 1:
362
+ time.sleep(wait_time)
363
+ continue
364
+
365
+ raise
366
+
367
+ if model_name == PRIMARY_MODEL and last_error:
368
+ continue
369
+
370
+ raise last_error
371
+
372
+
373
+ def analyze_error_simple(log: str, context: str, max_retries: int = 3) -> str:
374
+ """Analyze error and return raw text (for backward compatibility).
375
+
376
+ Args:
377
+ log: The error log or traceback
378
+ context: Source code context around the error
379
+ max_retries: Number of retries for rate limit errors
380
+
381
+ Returns:
382
+ Raw text fix from Gemini
383
+ """
384
+ result = analyze_error(log, context, max_retries)
385
+ return result.full_code_block or result.raw_response
386
+
387
+
388
+ def _build_prompt(log: str, context: str) -> str:
389
+ """Build the user prompt from log and context."""
390
+ parts = ["## ERROR LOG", log]
391
+
392
+ if context:
393
+ parts.extend(["", "## SOURCE CONTEXT", context])
394
+
395
+ parts.extend([
396
+ "",
397
+ "## INSTRUCTIONS",
398
+ "Analyze the error above. Return the corrected code as JSON.",
399
+ "The full_code_block should contain the complete fixed code, ready to replace the original.",
400
+ "If no specific file path is in the error traceback, set filepath to null.",
401
+ ])
402
+
403
+ return "\n".join(parts)
404
+
405
+
406
+ def _build_prompt_v2(log: str, context: str) -> str:
407
+ """Build V2 prompt with upstream context support."""
408
+ parts = ["## ERROR LOG / TRACEBACK", log]
409
+
410
+ if context:
411
+ parts.extend(["", context]) # Context already formatted by ContextBuilder
412
+
413
+ parts.extend([
414
+ "",
415
+ "## INSTRUCTIONS",
416
+ "Analyze the error above with deep project understanding.",
417
+ "Consider the full call chain and upstream context to identify root causes.",
418
+ "Return the corrected code as JSON with root cause analysis if applicable.",
419
+ "If the bug originates in a different file, set root_cause_file and provide fixes for both.",
420
+ "If no specific file path is in the traceback, set filepath to null.",
421
+ ])
422
+
423
+ return "\n".join(parts)