CodeCommunity commited on
Commit
2522c3f
·
verified ·
1 Parent(s): 4541812

Update app/services/reviewer_service.py

Browse files
Files changed (1) hide show
  1. app/services/reviewer_service.py +128 -103
app/services/reviewer_service.py CHANGED
@@ -1,58 +1,116 @@
1
  import json
2
  import logging
3
  import re
 
 
 
4
  from typing import List, Dict
5
  from concurrent.futures import ThreadPoolExecutor, as_completed
6
  from app.core.model_loader import llm_engine
7
 
8
  logger = logging.getLogger(__name__)
9
 
 
10
 
11
  class AIReviewerService:
12
  def __init__(self):
13
- # Keep worker count under the Gemini free-tier RPM limit (15/min).
14
- # 3 workers gives real parallelism without immediately tripping 429s,
15
- # since llm_engine already has its own RPM guard + backoff on top.
16
- self.max_concurrent_batches = 50
17
  self.batch_size = 10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  def review_batch_code(self, files: list) -> list:
20
- """
21
- Splits files into batches and reviews them CONCURRENTLY using a thread
22
- pool, instead of the old sequential for-loop. Each batch still makes
23
- one blocking call to llm_engine.generate(), but multiple batches are
24
- now in flight at once.
25
- """
26
- batches = [
27
- files[i : i + self.batch_size]
28
- for i in range(0, len(files), self.batch_size)
29
- ]
30
-
31
- if not batches:
32
  return []
33
 
34
- results: list = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
 
36
  with ThreadPoolExecutor(max_workers=self.max_concurrent_batches) as pool:
37
  future_to_batch = {
38
  pool.submit(self._process_batch, batch): batch for batch in batches
39
  }
40
-
41
  for future in as_completed(future_to_batch):
42
  batch = future_to_batch[future]
43
  try:
44
  batch_results = future.result()
45
- results.extend(batch_results)
46
  except Exception as e:
47
- # If a whole batch blew up unexpectedly (shouldn't normally
48
- # happen since _process_batch catches its own errors), fall
49
- # back to per-file error entries so the response shape
50
- # stays consistent for the Android client.
51
- logger.error(f"Unhandled batch failure: {e}")
52
- for f in batch:
53
- results.append(
54
  {
55
- "fileName": f.fileName,
56
  "vulnerabilities": [],
57
  "improvement_suggestions": [],
58
  "metrics": {"complexity": 0, "maintainability": 0},
@@ -60,36 +118,49 @@ class AIReviewerService:
60
  }
61
  )
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  return results
64
 
65
  def _process_batch(self, batch: list) -> list:
66
- """
67
- Processes a single batch of files: builds the prompt, calls Gemini,
68
- parses the response, and normalizes each file's result. This is the
69
- unit of work that now runs concurrently across threads.
70
- """
71
  combined_code = ""
72
  file_names = []
73
-
74
  for f in batch:
75
  raw_content = f.content or ""
76
- # Limit to 6k chars per file to stay within context window safely
77
- minified_content = self._minify_code(raw_content[:6000], f.fileName)
78
-
79
  combined_code += f"\n--- FILE: {f.fileName} ---\n{minified_content}\n"
80
  file_names.append(f.fileName)
81
 
82
- prompt = f"""
83
- Analyze the following {len(batch)} source code files for security vulnerabilities and code quality.
84
  {combined_code}
 
 
 
 
 
85
 
86
- Task:
87
- 1. Detect severe security/logic issues (Vulnerabilities).
88
- 2. If a vulnerability is found, provide a concise 'suggestion' on how to fix it.
89
- 3. If NO vulnerabilities are found in a file, provide a list of 'improvement_suggestions' (clean code, performance, or architecture tips).
90
- 4. Provide metrics for complexity and maintainability (scale 1-10).
91
-
92
- Output a JSON array (exactly 1 object per file):
93
  [
94
  {{
95
  "fileName": "exact/path/from/header",
@@ -98,21 +169,19 @@ Output a JSON array (exactly 1 object per file):
98
  "type": "SQLi/Logic/etc",
99
  "line": 10,
100
  "description": "Short explanation",
101
- "suggestion": "Specific code fix"
102
  }}
103
  ],
104
- "improvement_suggestions": ["Tip 1", "Tip 2"],
105
  "metrics": {{"complexity": 3, "maintainability": 8}}
106
  }}
107
- ]
108
- """
109
- batch_results = []
110
 
 
111
  try:
112
- response_text = llm_engine.generate(prompt, max_tokens=8192)
113
  parsed = self._parse_json(response_text)
114
 
115
- # Map results by fileName for easy lookup
116
  processed_map = {
117
  item.get("fileName"): item for item in parsed if isinstance(item, dict)
118
  }
@@ -120,45 +189,29 @@ Output a JSON array (exactly 1 object per file):
120
  for fn in file_names:
121
  if fn in processed_map:
122
  res = processed_map[fn]
123
-
124
- # Ensure all required keys exist to prevent Android
125
- # serialization errors
126
  res.setdefault("vulnerabilities", [])
127
  res.setdefault("improvement_suggestions", [])
128
  res.setdefault("metrics", {"complexity": 5, "maintainability": 5})
129
 
130
- # Ensure every vulnerability has a suggestion field
131
  for vuln in res["vulnerabilities"]:
132
  if "suggestion" not in vuln:
133
- vuln["suggestion"] = (
134
- "Review the implementation logic for improved safety."
135
- )
136
-
137
  batch_results.append(res)
138
  else:
139
- # Fallback if the AI skipped a file in its response
140
  batch_results.append(
141
  {
142
  "fileName": fn,
143
  "vulnerabilities": [],
144
- "improvement_suggestions": [
145
- "No immediate improvements identified."
146
- ],
147
  "metrics": {"complexity": 1, "maintainability": 10},
148
  }
149
  )
150
-
151
  except Exception as e:
152
  logger.error(f"Batch processing error: {e}")
153
  error_str = str(e)
154
  is_config_error = any(
155
  x in error_str
156
- for x in [
157
- "QUOTA_EXCEEDED",
158
- "RATE_LIMIT_EXCEEDED",
159
- "MISSING_API_KEY",
160
- "GENERATION_FAILED",
161
- ]
162
  )
163
  for fn in file_names:
164
  batch_results.append(
@@ -170,72 +223,44 @@ Output a JSON array (exactly 1 object per file):
170
  "error": error_str if is_config_error else "analysis_failed",
171
  }
172
  )
173
-
174
  return batch_results
175
 
176
  def _minify_code(self, code: str, filename: str) -> str:
177
- """
178
- Removes comments, empty lines, and logs to optimize token usage.
179
- """
180
  lines = code.split("\n")
181
  cleaned_lines = []
182
-
183
  is_python = filename.endswith(".py")
184
- is_js_style = filename.endswith(
185
- (".js", ".ts", ".jsx", ".tsx", ".java", ".kt", ".c", ".cpp")
186
- )
187
 
188
  for line in lines:
189
  stripped = line.strip()
190
-
191
  if not stripped:
192
  continue
193
-
194
- # Skip comments
195
  if is_python and stripped.startswith("#"):
196
  continue
197
  if is_js_style and stripped.startswith(("//", "/*", "*")):
198
  continue
199
-
200
- # Skip common logging
201
- if any(
202
- log in stripped
203
- for log in ["console.log", "print(", "logger.", "Log.d", "Log.e"]
204
- ):
205
  continue
206
-
207
- # Strip inline comments
208
  if is_python and " #" in line:
209
  line = line.split(" #", 1)[0]
210
  if is_js_style and " //" in line:
211
  line = line.split(" //", 1)[0]
212
-
213
- if not line.strip():
214
- continue
215
-
216
- cleaned_lines.append(line.rstrip())
217
 
218
  return "\n".join(cleaned_lines)
219
 
220
  def _parse_json(self, text: str):
221
- """
222
- Cleans and parses the LLM response into a Python list/dict.
223
- """
224
  text = text.strip()
225
  if not text:
226
  return []
227
-
228
- # Clean markdown formatting if present
229
  text = re.sub(r"^[^[]*\[", "[", text)
230
  text = re.sub(r"\][^]]*$", "]", text)
231
  text = text.replace("```json", "").replace("```", "").strip()
232
-
233
  try:
234
  return json.loads(text)
235
  except json.JSONDecodeError as e:
236
  logger.warning(f"JSON Decode Error: {e}")
237
  return []
238
 
239
-
240
- # Instantiate the service
241
  service = AIReviewerService()
 
1
  import json
2
  import logging
3
  import re
4
+ import hashlib
5
+ import sqlite3
6
+ import threading
7
  from typing import List, Dict
8
  from concurrent.futures import ThreadPoolExecutor, as_completed
9
  from app.core.model_loader import llm_engine
10
 
11
  logger = logging.getLogger(__name__)
12
 
13
+ DB_PATH = "review_cache.db"
14
 
15
  class AIReviewerService:
16
  def __init__(self):
17
+ self.max_concurrent_batches = 3
 
 
 
18
  self.batch_size = 10
19
+ self.db_lock = threading.Lock()
20
+ self._init_db()
21
+
22
+ def _init_db(self):
23
+ """Initializes SQLite cache table."""
24
+ with self.db_lock:
25
+ with sqlite3.connect(DB_PATH) as conn:
26
+ cursor = conn.cursor()
27
+ cursor.execute("""
28
+ CREATE TABLE IF NOT EXISTS review_cache (
29
+ file_hash TEXT PRIMARY KEY,
30
+ result_json TEXT
31
+ )
32
+ """)
33
+ conn.commit()
34
+
35
+ def _generate_file_hash(self, file_obj) -> str:
36
+ """Generates a unique SHA256 digest based on path and content."""
37
+ content = file_obj.content or ""
38
+ raw_payload = f"{file_obj.fileName}:{content}"
39
+ return hashlib.sha256(raw_payload.encode("utf-8")).hexdigest()
40
+
41
+ def _get_cached_result(self, file_hash: str) -> dict:
42
+ """Thread-safe SQLite fetch."""
43
+ with self.db_lock:
44
+ with sqlite3.connect(DB_PATH) as conn:
45
+ cursor = conn.cursor()
46
+ cursor.execute("SELECT result_json FROM review_cache WHERE file_hash = ?", (file_hash,))
47
+ row = cursor.fetchone()
48
+ if row:
49
+ try:
50
+ return json.loads(row[0])
51
+ except json.JSONDecodeError:
52
+ return None
53
+ return None
54
+
55
+ def _save_cached_results(self, hash_result_pairs: List[tuple]):
56
+ """Thread-safe batch insert into SQLite."""
57
+ if not hash_result_pairs:
58
+ return
59
+ with self.db_lock:
60
+ with sqlite3.connect(DB_PATH) as conn:
61
+ cursor = conn.cursor()
62
+ cursor.executemany(
63
+ "INSERT OR REPLACE INTO review_cache (file_hash, result_json) VALUES (?, ?)",
64
+ [(h, json.dumps(res)) for h, res in hash_result_pairs]
65
+ )
66
+ conn.commit()
67
 
68
  def review_batch_code(self, files: list) -> list:
69
+ if not files:
 
 
 
 
 
 
 
 
 
 
 
70
  return []
71
 
72
+ results = [None] * len(files)
73
+ uncached_files = []
74
+ uncached_indices = []
75
+
76
+ # 1. Check SQLite cache
77
+ for idx, f in enumerate(files):
78
+ file_hash = self._generate_file_hash(f)
79
+ cached_res = self._get_cached_result(file_hash)
80
+ if cached_res:
81
+ results[idx] = cached_res
82
+ else:
83
+ uncached_files.append(f)
84
+ uncached_indices.append(idx)
85
+
86
+ if not uncached_files:
87
+ logger.info(f"⚡ Served all {len(files)} files instantly from SQLite cache.")
88
+ return results
89
+
90
+ logger.info(f"SQLite cache hit: {len(files) - len(uncached_files)}/{len(files)} files. Reviewing {len(uncached_files)} uncached files.")
91
+
92
+ # 2. Batch process uncached files via Gemini
93
+ batches = [
94
+ uncached_files[i : i + self.batch_size]
95
+ for i in range(0, len(uncached_files), self.batch_size)
96
+ ]
97
 
98
+ new_results = []
99
  with ThreadPoolExecutor(max_workers=self.max_concurrent_batches) as pool:
100
  future_to_batch = {
101
  pool.submit(self._process_batch, batch): batch for batch in batches
102
  }
 
103
  for future in as_completed(future_to_batch):
104
  batch = future_to_batch[future]
105
  try:
106
  batch_results = future.result()
107
+ new_results.extend(batch_results)
108
  except Exception as e:
109
+ logger.error(f"Batch failure: {e}")
110
+ for f_item in batch:
111
+ new_results.append(
 
 
 
 
112
  {
113
+ "fileName": f_item.fileName,
114
  "vulnerabilities": [],
115
  "improvement_suggestions": [],
116
  "metrics": {"complexity": 0, "maintainability": 0},
 
118
  }
119
  )
120
 
121
+ new_results_map = {item.get("fileName"): item for item in new_results}
122
+ to_cache = []
123
+
124
+ # 3. Assemble final response list and write to SQLite
125
+ for idx, f in zip(uncached_indices, uncached_files):
126
+ res = new_results_map.get(
127
+ f.fileName,
128
+ {
129
+ "fileName": f.fileName,
130
+ "vulnerabilities": [],
131
+ "improvement_suggestions": [],
132
+ "metrics": {"complexity": 0, "maintainability": 0},
133
+ "error": "analysis_failed",
134
+ },
135
+ )
136
+
137
+ if "error" not in res:
138
+ file_hash = self._generate_file_hash(f)
139
+ to_cache.append((file_hash, res))
140
+
141
+ results[idx] = res
142
+
143
+ self._save_cached_results(to_cache)
144
  return results
145
 
146
  def _process_batch(self, batch: list) -> list:
 
 
 
 
 
147
  combined_code = ""
148
  file_names = []
 
149
  for f in batch:
150
  raw_content = f.content or ""
151
+ minified_content = self._minify_code(raw_content[:5000], f.fileName)
 
 
152
  combined_code += f"\n--- FILE: {f.fileName} ---\n{minified_content}\n"
153
  file_names.append(f.fileName)
154
 
155
+ prompt = f"""Analyze these {len(batch)} source files for vulnerabilities and quality.
 
156
  {combined_code}
157
+ Instructions:
158
+ 1. Identify severe security/logic issues as 'vulnerabilities' with a 'suggestion' fix.
159
+ 2. If no vulnerabilities exist, give 1-2 concise 'improvement_suggestions'.
160
+ 3. Assign maintainability and complexity metrics (1-10).
161
+ 4. Keep descriptions short to maintain fast execution.
162
 
163
+ Return JSON array (1 object per file):
 
 
 
 
 
 
164
  [
165
  {{
166
  "fileName": "exact/path/from/header",
 
169
  "type": "SQLi/Logic/etc",
170
  "line": 10,
171
  "description": "Short explanation",
172
+ "suggestion": "Specific fix"
173
  }}
174
  ],
175
+ "improvement_suggestions": ["Tip 1"],
176
  "metrics": {{"complexity": 3, "maintainability": 8}}
177
  }}
178
+ ]"""
 
 
179
 
180
+ batch_results = []
181
  try:
182
+ response_text = llm_engine.generate(prompt, max_tokens=4096)
183
  parsed = self._parse_json(response_text)
184
 
 
185
  processed_map = {
186
  item.get("fileName"): item for item in parsed if isinstance(item, dict)
187
  }
 
189
  for fn in file_names:
190
  if fn in processed_map:
191
  res = processed_map[fn]
 
 
 
192
  res.setdefault("vulnerabilities", [])
193
  res.setdefault("improvement_suggestions", [])
194
  res.setdefault("metrics", {"complexity": 5, "maintainability": 5})
195
 
 
196
  for vuln in res["vulnerabilities"]:
197
  if "suggestion" not in vuln:
198
+ vuln["suggestion"] = "Review implementation logic for safety."
 
 
 
199
  batch_results.append(res)
200
  else:
 
201
  batch_results.append(
202
  {
203
  "fileName": fn,
204
  "vulnerabilities": [],
205
+ "improvement_suggestions": ["No immediate improvements identified."],
 
 
206
  "metrics": {"complexity": 1, "maintainability": 10},
207
  }
208
  )
 
209
  except Exception as e:
210
  logger.error(f"Batch processing error: {e}")
211
  error_str = str(e)
212
  is_config_error = any(
213
  x in error_str
214
+ for x in ["QUOTA_EXCEEDED", "RATE_LIMIT_EXCEEDED", "MISSING_API_KEY", "GENERATION_FAILED"]
 
 
 
 
 
215
  )
216
  for fn in file_names:
217
  batch_results.append(
 
223
  "error": error_str if is_config_error else "analysis_failed",
224
  }
225
  )
 
226
  return batch_results
227
 
228
  def _minify_code(self, code: str, filename: str) -> str:
 
 
 
229
  lines = code.split("\n")
230
  cleaned_lines = []
 
231
  is_python = filename.endswith(".py")
232
+ is_js_style = filename.endswith((".js", ".ts", ".jsx", ".tsx", ".java", ".kt", ".c", ".cpp"))
 
 
233
 
234
  for line in lines:
235
  stripped = line.strip()
 
236
  if not stripped:
237
  continue
 
 
238
  if is_python and stripped.startswith("#"):
239
  continue
240
  if is_js_style and stripped.startswith(("//", "/*", "*")):
241
  continue
242
+ if any(log in stripped for log in ["console.log", "print(", "logger.", "Log.d", "Log.e"]):
 
 
 
 
 
243
  continue
 
 
244
  if is_python and " #" in line:
245
  line = line.split(" #", 1)[0]
246
  if is_js_style and " //" in line:
247
  line = line.split(" //", 1)[0]
248
+ if line.strip():
249
+ cleaned_lines.append(line.rstrip())
 
 
 
250
 
251
  return "\n".join(cleaned_lines)
252
 
253
  def _parse_json(self, text: str):
 
 
 
254
  text = text.strip()
255
  if not text:
256
  return []
 
 
257
  text = re.sub(r"^[^[]*\[", "[", text)
258
  text = re.sub(r"\][^]]*$", "]", text)
259
  text = text.replace("```json", "").replace("```", "").strip()
 
260
  try:
261
  return json.loads(text)
262
  except json.JSONDecodeError as e:
263
  logger.warning(f"JSON Decode Error: {e}")
264
  return []
265
 
 
 
266
  service = AIReviewerService()