prism-code-review 2.0.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.
- prism/__init__.py +87 -0
- prism/__main__.py +13 -0
- prism/agents.py +191 -0
- prism/agents_policy.py +277 -0
- prism/agents_quality.py +584 -0
- prism/agents_security.py +243 -0
- prism/agents_test.py +145 -0
- prism/ai_architecture.py +65 -0
- prism/ai_common.py +32 -0
- prism/ai_findings.py +181 -0
- prism/ai_graph.py +224 -0
- prism/ai_performance.py +65 -0
- prism/ai_pipeline.py +447 -0
- prism/ai_policy.py +65 -0
- prism/ai_providers.py +514 -0
- prism/ai_quality.py +65 -0
- prism/ai_security.py +65 -0
- prism/ai_test.py +65 -0
- prism/autofix.py +360 -0
- prism/cli.py +248 -0
- prism/cli_commands.py +711 -0
- prism/cli_config.py +202 -0
- prism/cli_review.py +558 -0
- prism/codebase_cache.py +155 -0
- prism/commands_common.py +365 -0
- prism/commands_reports.py +212 -0
- prism/commands_setup.py +703 -0
- prism/feedback.py +254 -0
- prism/ignore.py +344 -0
- prism/langs.py +251 -0
- prism/logging_config.py +52 -0
- prism/lsp.py +194 -0
- prism/modes.py +94 -0
- prism/reports.py +277 -0
- prism/token_usage.py +93 -0
- prism_code_review-2.0.0.dist-info/METADATA +1392 -0
- prism_code_review-2.0.0.dist-info/RECORD +41 -0
- prism_code_review-2.0.0.dist-info/WHEEL +5 -0
- prism_code_review-2.0.0.dist-info/entry_points.txt +2 -0
- prism_code_review-2.0.0.dist-info/licenses/LICENSE +21 -0
- prism_code_review-2.0.0.dist-info/top_level.txt +1 -0
prism/ai_providers.py
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""LLM provider initialization and API call dispatch.
|
|
2
|
+
|
|
3
|
+
Supports gemini, claude, and openai. Provider is selected via the
|
|
4
|
+
MODEL_PROVIDER env var (default: gemini). Each provider returns a
|
|
5
|
+
(client, provider_name, call_fn) tuple; call_fn is bound to the right
|
|
6
|
+
API shape so pipeline code never branches on provider.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
import random
|
|
14
|
+
import logging
|
|
15
|
+
import importlib
|
|
16
|
+
import concurrent.futures
|
|
17
|
+
|
|
18
|
+
from prism import token_usage
|
|
19
|
+
|
|
20
|
+
def _int_env(name, default):
|
|
21
|
+
"""Read an int env var, falling back to the default when unset or not a number.
|
|
22
|
+
|
|
23
|
+
A typo in a tuning variable must not take the whole CLI down at import time.
|
|
24
|
+
"""
|
|
25
|
+
try:
|
|
26
|
+
return int(os.getenv(name, default))
|
|
27
|
+
except (TypeError, ValueError):
|
|
28
|
+
return int(default)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Per-attempt wall-clock cap. A provider that accepts the socket but never
|
|
32
|
+
# answers (or an HTTP client that ignores its timeout) must not stall the CLI
|
|
33
|
+
# for minutes: every attempt is abandoned after this many seconds.
|
|
34
|
+
API_TIMEOUT = _int_env("PRISM_API_TIMEOUT", 30)
|
|
35
|
+
MAX_RETRIES = _int_env("PRISM_MAX_RETRIES", 2)
|
|
36
|
+
|
|
37
|
+
# How much file content is sent to the model in one call. Truncation is a cost
|
|
38
|
+
# control, not a correctness limit: the skeleton and the import graph carry the
|
|
39
|
+
# structure, so a large file still gets reviewed.
|
|
40
|
+
MAX_CONTENT_CHARS = _int_env("PRISM_MAX_CONTENT_CHARS", 16000)
|
|
41
|
+
|
|
42
|
+
# Retry pacing: exponential backoff with jitter, capped by a total budget so a
|
|
43
|
+
# bad provider cannot stall the CLI. Defaults keep a single flaky call well
|
|
44
|
+
# under a second of sleeping while still absorbing transient 504s.
|
|
45
|
+
RETRY_BASE_DELAY = float(os.getenv("PRISM_RETRY_BASE_DELAY", "0.2"))
|
|
46
|
+
RETRY_MAX_DELAY = float(os.getenv("PRISM_RETRY_MAX_DELAY", "1.0"))
|
|
47
|
+
RETRY_BUDGET = float(os.getenv("PRISM_RETRY_BUDGET", "5"))
|
|
48
|
+
|
|
49
|
+
# Simple circuit breaker: after repeated failures, fail fast for a cooldown
|
|
50
|
+
# instead of making every agent wait through the full retry ladder.
|
|
51
|
+
CIRCUIT_THRESHOLD = _int_env("PRISM_CIRCUIT_THRESHOLD", 5)
|
|
52
|
+
CIRCUIT_COOLDOWN = float(os.getenv("PRISM_CIRCUIT_COOLDOWN", "60"))
|
|
53
|
+
|
|
54
|
+
_logger = logging.getLogger(__name__)
|
|
55
|
+
_no_key_warned = False
|
|
56
|
+
_circuit_failures = 0
|
|
57
|
+
_circuit_open_until = 0.0
|
|
58
|
+
|
|
59
|
+
# Why get_model() most recently returned None. A missing SDK and a missing key
|
|
60
|
+
# both produce (None, None, None), so callers used to report "no API key" for
|
|
61
|
+
# both — a developer who ran `pip install prism_code_review-2.0.0-py3-none-any.whl`
|
|
62
|
+
# (no extras) was told to set a key that was already set. The reason is kept
|
|
63
|
+
# here so the pipeline can name the actual problem.
|
|
64
|
+
_MODEL_FAILURE = {"reason": None, "provider": None, "detail": ""}
|
|
65
|
+
|
|
66
|
+
# Which pip extra supplies the SDK for each client shape. pyproject declares
|
|
67
|
+
# gemini, claude and openai; the five OpenAI-compatible providers reuse the
|
|
68
|
+
# `openai` client, so they share one extra.
|
|
69
|
+
_EXTRA_BY_KIND = {"gemini": "gemini", "claude": "claude", "openai": "openai"}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _set_model_failure(reason, provider=None, detail=""):
|
|
73
|
+
"""Record why get_model() failed so callers can explain it accurately."""
|
|
74
|
+
_MODEL_FAILURE["reason"] = reason
|
|
75
|
+
_MODEL_FAILURE["provider"] = provider
|
|
76
|
+
_MODEL_FAILURE["detail"] = detail
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def last_model_failure():
|
|
80
|
+
"""Return (reason, provider, detail) for the most recent failure.
|
|
81
|
+
|
|
82
|
+
``reason`` is one of "no_key", "no_sdk", "unknown_provider", or None when
|
|
83
|
+
the last call succeeded. ``provider`` is the resolved provider name, which
|
|
84
|
+
get_model() cannot return alongside the None triple, so callers can name
|
|
85
|
+
the provider in the message even though every value came back None.
|
|
86
|
+
"""
|
|
87
|
+
return (_MODEL_FAILURE["reason"], _MODEL_FAILURE["provider"],
|
|
88
|
+
_MODEL_FAILURE["detail"])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def sdk_extra(provider):
|
|
92
|
+
"""Return the pip extra that installs the SDK for ``provider``, or None."""
|
|
93
|
+
spec = PROVIDERS.get(_resolve_provider(provider))
|
|
94
|
+
if spec is None:
|
|
95
|
+
return None
|
|
96
|
+
return _EXTRA_BY_KIND.get(spec["kind"])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Distribution name on PyPI. The "install it with" hint is printed by three
|
|
100
|
+
# modules, so the name lives here once instead of being repeated per call site.
|
|
101
|
+
DIST_NAME = "prism-code-review"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def install_hint(extra="all"):
|
|
105
|
+
"""Return the pip command that installs ``extra`` of this distribution."""
|
|
106
|
+
return "pip install '{}[{}]'".format(DIST_NAME, extra)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def sdk_install_hint(provider):
|
|
110
|
+
"""Return an actionable install command for a provider's missing SDK."""
|
|
111
|
+
extra = sdk_extra(provider)
|
|
112
|
+
if not extra:
|
|
113
|
+
return None
|
|
114
|
+
return install_hint(extra)
|
|
115
|
+
|
|
116
|
+
# Provider catalogue. `kind` selects the client shape; `key`/`model` are the
|
|
117
|
+
# env vars that hold the API key and model override. `base_url` (+ optional
|
|
118
|
+
# `base_url_default`) targets an OpenAI-compatible endpoint, which is how the
|
|
119
|
+
# free tiers (OpenRouter, Groq) and paid alternatives (Together, DeepSeek,
|
|
120
|
+
# Mistral) are supported without new client code.
|
|
121
|
+
PROVIDERS = {
|
|
122
|
+
"gemini": {
|
|
123
|
+
"kind": "gemini", "key": "GEMINI_API_KEY",
|
|
124
|
+
"model": "GEMINI_MODEL", "default_model": "gemini-3.5-flash-lite",
|
|
125
|
+
},
|
|
126
|
+
"claude": {
|
|
127
|
+
"kind": "claude", "key": "ANTHROPIC_API_KEY",
|
|
128
|
+
"model": "CLAUDE_MODEL", "default_model": "claude-3-5-sonnet-20241022",
|
|
129
|
+
},
|
|
130
|
+
"openai": {
|
|
131
|
+
"kind": "openai", "key": "OPENAI_API_KEY",
|
|
132
|
+
"model": "OPENAI_MODEL", "default_model": "gpt-4o",
|
|
133
|
+
"base_url": "OPENAI_BASE_URL",
|
|
134
|
+
},
|
|
135
|
+
"openrouter": {
|
|
136
|
+
"kind": "openai", "key": "OPENROUTER_API_KEY",
|
|
137
|
+
"model": "OPENROUTER_MODEL", "default_model": "openai/gpt-4o-mini",
|
|
138
|
+
"base_url": "OPENROUTER_BASE_URL",
|
|
139
|
+
"base_url_default": "https://openrouter.ai/api/v1",
|
|
140
|
+
},
|
|
141
|
+
"groq": {
|
|
142
|
+
"kind": "openai", "key": "GROQ_API_KEY",
|
|
143
|
+
"model": "GROQ_MODEL", "default_model": "llama-3.3-70b-versatile",
|
|
144
|
+
"base_url": "GROQ_BASE_URL",
|
|
145
|
+
"base_url_default": "https://api.groq.com/openai/v1",
|
|
146
|
+
},
|
|
147
|
+
"together": {
|
|
148
|
+
"kind": "openai", "key": "TOGETHER_API_KEY",
|
|
149
|
+
"model": "TOGETHER_MODEL",
|
|
150
|
+
"default_model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
|
151
|
+
"base_url": "TOGETHER_BASE_URL",
|
|
152
|
+
"base_url_default": "https://api.together.xyz/v1",
|
|
153
|
+
},
|
|
154
|
+
"deepseek": {
|
|
155
|
+
"kind": "openai", "key": "DEEPSEEK_API_KEY",
|
|
156
|
+
"model": "DEEPSEEK_MODEL", "default_model": "deepseek-chat",
|
|
157
|
+
"base_url": "DEEPSEEK_BASE_URL",
|
|
158
|
+
"base_url_default": "https://api.deepseek.com",
|
|
159
|
+
},
|
|
160
|
+
"mistral": {
|
|
161
|
+
"kind": "openai", "key": "MISTRAL_API_KEY",
|
|
162
|
+
"model": "MISTRAL_MODEL", "default_model": "mistral-large-latest",
|
|
163
|
+
"base_url": "MISTRAL_BASE_URL",
|
|
164
|
+
"base_url_default": "https://api.mistral.ai/v1",
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
PROVIDER_ALIASES = {
|
|
169
|
+
"anthropic": "claude", "google": "gemini",
|
|
170
|
+
"gpt": "openai", "open-ai": "openai",
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# Which provider/model the current call belongs to, for token accounting.
|
|
174
|
+
_ACTIVE_MODEL = {"provider": "gemini", "model": "gemini-3.5-flash-lite"}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _warn_no_key(provider, env_var):
|
|
178
|
+
"""Print a one-time, actionable message when no API key is configured.
|
|
179
|
+
|
|
180
|
+
Returning None silently made every AI agent return [] with no indication
|
|
181
|
+
why — a developer would assume their code is clean. This makes the
|
|
182
|
+
failure loud instead.
|
|
183
|
+
"""
|
|
184
|
+
global _no_key_warned
|
|
185
|
+
if _no_key_warned:
|
|
186
|
+
return
|
|
187
|
+
_no_key_warned = True
|
|
188
|
+
_logger.warning(
|
|
189
|
+
"No API key for %s. AI agents are disabled. "
|
|
190
|
+
"Run `prism init` to scaffold .env, then add %s=your_key. "
|
|
191
|
+
"Deterministic regex agents still run with --effort lite.",
|
|
192
|
+
provider, env_var,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _is_placeholder_key(key):
|
|
197
|
+
"""Reject obviously fake keys so tests and misconfigured installs fail fast."""
|
|
198
|
+
if not key:
|
|
199
|
+
return True
|
|
200
|
+
lowered = key.lower()
|
|
201
|
+
return any(marker in lowered for marker in (
|
|
202
|
+
"your-", "example", "placeholder", "changeme", "xxx",
|
|
203
|
+
"api-key-here", "token-here", "secret-here",
|
|
204
|
+
))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _resolve_provider(provider):
|
|
208
|
+
"""Normalize a provider name (aliases included) or read MODEL_PROVIDER."""
|
|
209
|
+
provider = (provider or os.getenv("MODEL_PROVIDER", "gemini")).lower()
|
|
210
|
+
return PROVIDER_ALIASES.get(provider, provider)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _record_usage(input_tokens, output_tokens, prompt_text, response_text):
|
|
214
|
+
"""Record one call's tokens, estimating when the provider gave none."""
|
|
215
|
+
provider = _ACTIVE_MODEL["provider"]
|
|
216
|
+
model = _ACTIVE_MODEL["model"]
|
|
217
|
+
if input_tokens or output_tokens:
|
|
218
|
+
token_usage.record(provider, model, input_tokens, output_tokens)
|
|
219
|
+
else:
|
|
220
|
+
token_usage.record(
|
|
221
|
+
provider, model,
|
|
222
|
+
token_usage.estimate_tokens(prompt_text),
|
|
223
|
+
token_usage.estimate_tokens(response_text),
|
|
224
|
+
estimated=True,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def get_model(provider=None, model_name=None):
|
|
229
|
+
"""Lazy-load an LLM client. Returns (client, provider, call_fn) or (None, None, None)."""
|
|
230
|
+
provider = _resolve_provider(provider)
|
|
231
|
+
spec = PROVIDERS.get(provider)
|
|
232
|
+
if spec is None:
|
|
233
|
+
_logger.error("Unknown MODEL_PROVIDER: %s", provider)
|
|
234
|
+
_set_model_failure("unknown_provider", provider, provider)
|
|
235
|
+
return None, None, None
|
|
236
|
+
|
|
237
|
+
model_name = model_name or os.getenv(spec["model"]) or spec["default_model"]
|
|
238
|
+
api_key = os.getenv(spec["key"])
|
|
239
|
+
if not api_key or _is_placeholder_key(api_key):
|
|
240
|
+
_warn_no_key(provider, spec["key"])
|
|
241
|
+
_set_model_failure("no_key", provider, spec["key"])
|
|
242
|
+
return None, None, None
|
|
243
|
+
|
|
244
|
+
_ACTIVE_MODEL["provider"] = provider
|
|
245
|
+
_ACTIVE_MODEL["model"] = model_name
|
|
246
|
+
kind = spec["kind"]
|
|
247
|
+
|
|
248
|
+
if kind == "gemini":
|
|
249
|
+
try:
|
|
250
|
+
import google.generativeai as genai
|
|
251
|
+
genai.configure(api_key=api_key)
|
|
252
|
+
return genai.GenerativeModel(model_name), provider, _call_fn
|
|
253
|
+
except Exception as exc:
|
|
254
|
+
_logger.error("Failed to initialize Gemini: %s", exc)
|
|
255
|
+
_set_model_failure("no_sdk", provider, str(exc))
|
|
256
|
+
return None, None, None
|
|
257
|
+
|
|
258
|
+
if kind == "claude":
|
|
259
|
+
try:
|
|
260
|
+
anthropic = __import__("anthropic")
|
|
261
|
+
return anthropic.Anthropic(api_key=api_key), provider, _call_claude
|
|
262
|
+
except Exception as exc:
|
|
263
|
+
_logger.error("Failed to initialize Claude: %s", exc)
|
|
264
|
+
_set_model_failure("no_sdk", provider, str(exc))
|
|
265
|
+
return None, None, None
|
|
266
|
+
|
|
267
|
+
# openai / OpenAI-compatible
|
|
268
|
+
try:
|
|
269
|
+
openai = importlib.import_module("openai")
|
|
270
|
+
kwargs = {"api_key": api_key}
|
|
271
|
+
base_url = (os.getenv(spec.get("base_url", ""))
|
|
272
|
+
or spec.get("base_url_default"))
|
|
273
|
+
if base_url:
|
|
274
|
+
kwargs["base_url"] = base_url
|
|
275
|
+
return openai.OpenAI(**kwargs), provider, _call_openai
|
|
276
|
+
except Exception as exc:
|
|
277
|
+
_logger.error("Failed to initialize %s: %s", provider, exc)
|
|
278
|
+
_set_model_failure("no_sdk", provider, str(exc))
|
|
279
|
+
return None, None, None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _build_prompt(system_prompt, file_path, content, skeleton="", cross_file_context=""):
|
|
283
|
+
"""Assemble the user prompt from system + context + skeleton + content."""
|
|
284
|
+
parts = []
|
|
285
|
+
if cross_file_context:
|
|
286
|
+
parts.append(cross_file_context)
|
|
287
|
+
if skeleton:
|
|
288
|
+
parts.append("\n## Structural Skeleton (table of contents for this file)\n" + skeleton)
|
|
289
|
+
parts.append("\n## File: {}\n".format(file_path))
|
|
290
|
+
parts.append("\n## Content\n{}".format(content[:MAX_CONTENT_CHARS]))
|
|
291
|
+
return system_prompt + "\n" + "\n".join(parts)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _call_fn(model, system_prompt, file_path, content, agent_name,
|
|
295
|
+
skeleton="", timeout=None, cross_file_context=""):
|
|
296
|
+
"""Call Gemini API with retry logic and circuit breaker."""
|
|
297
|
+
if timeout is None:
|
|
298
|
+
timeout = API_TIMEOUT
|
|
299
|
+
prompt = _build_prompt(system_prompt, file_path, content, skeleton, cross_file_context)
|
|
300
|
+
|
|
301
|
+
def _do_generate(prompt_text, _timeout=timeout):
|
|
302
|
+
# Ask for JSON at the API level: it removes the markdown fences and
|
|
303
|
+
# prose preambles that used to make chunked reviews fail parsing.
|
|
304
|
+
response = model.generate_content(
|
|
305
|
+
prompt_text,
|
|
306
|
+
generation_config={"response_mime_type": "application/json"},
|
|
307
|
+
request_options={"timeout": _timeout},
|
|
308
|
+
)
|
|
309
|
+
text = response.text or "{}"
|
|
310
|
+
usage = getattr(response, "usage_metadata", None)
|
|
311
|
+
_record_usage(
|
|
312
|
+
getattr(usage, "prompt_token_count", 0),
|
|
313
|
+
getattr(usage, "candidates_token_count", 0),
|
|
314
|
+
prompt_text, text,
|
|
315
|
+
)
|
|
316
|
+
return text
|
|
317
|
+
|
|
318
|
+
return _call_with_retry(_do_generate, prompt, agent_name, timeout)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _call_claude(model, system_prompt, file_path, content, agent_name,
|
|
322
|
+
skeleton="", timeout=None, cross_file_context=""):
|
|
323
|
+
"""Call Claude API with retry logic and circuit breaker."""
|
|
324
|
+
if timeout is None:
|
|
325
|
+
timeout = API_TIMEOUT
|
|
326
|
+
user_prompt = _build_prompt("", file_path, content, skeleton, cross_file_context)
|
|
327
|
+
|
|
328
|
+
def _do_call():
|
|
329
|
+
response = model.messages.create(
|
|
330
|
+
model=_ACTIVE_MODEL["model"],
|
|
331
|
+
max_tokens=4096,
|
|
332
|
+
system=system_prompt,
|
|
333
|
+
messages=[{"role": "user", "content": user_prompt}],
|
|
334
|
+
)
|
|
335
|
+
text = response.content[0].text
|
|
336
|
+
usage = getattr(response, "usage", None)
|
|
337
|
+
_record_usage(
|
|
338
|
+
getattr(usage, "input_tokens", 0),
|
|
339
|
+
getattr(usage, "output_tokens", 0),
|
|
340
|
+
user_prompt, text,
|
|
341
|
+
)
|
|
342
|
+
return text
|
|
343
|
+
|
|
344
|
+
return _call_with_retry(_do_call, None, agent_name, timeout)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _call_openai(model, system_prompt, file_path, content, agent_name,
|
|
348
|
+
skeleton="", timeout=None, cross_file_context=""):
|
|
349
|
+
"""Call OpenAI API with retry logic and circuit breaker."""
|
|
350
|
+
if timeout is None:
|
|
351
|
+
timeout = API_TIMEOUT
|
|
352
|
+
user_prompt = _build_prompt("", file_path, content, skeleton, cross_file_context)
|
|
353
|
+
|
|
354
|
+
def _do_call():
|
|
355
|
+
response = model.chat.completions.create(
|
|
356
|
+
model=_ACTIVE_MODEL["model"],
|
|
357
|
+
messages=[
|
|
358
|
+
{"role": "system", "content": system_prompt},
|
|
359
|
+
{"role": "user", "content": user_prompt},
|
|
360
|
+
],
|
|
361
|
+
response_format={"type": "json_object"},
|
|
362
|
+
)
|
|
363
|
+
text = response.choices[0].message.content
|
|
364
|
+
usage = getattr(response, "usage", None)
|
|
365
|
+
_record_usage(
|
|
366
|
+
getattr(usage, "prompt_tokens", 0),
|
|
367
|
+
getattr(usage, "completion_tokens", 0),
|
|
368
|
+
user_prompt, text,
|
|
369
|
+
)
|
|
370
|
+
return text
|
|
371
|
+
|
|
372
|
+
return _call_with_retry(_do_call, None, agent_name, timeout)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _parse_json_response(text):
|
|
376
|
+
"""Extract a JSON object from a model response.
|
|
377
|
+
|
|
378
|
+
Models sometimes wrap JSON in prose or markdown fences, or emit trailing
|
|
379
|
+
commas. Return the parsed dict, or None if no valid object is present.
|
|
380
|
+
"""
|
|
381
|
+
if not text:
|
|
382
|
+
return None
|
|
383
|
+
cleaned = str(text).replace("```json", "").replace("```", "").strip()
|
|
384
|
+
try:
|
|
385
|
+
return json.loads(cleaned)
|
|
386
|
+
except (json.JSONDecodeError, TypeError):
|
|
387
|
+
pass
|
|
388
|
+
|
|
389
|
+
start = cleaned.find("{")
|
|
390
|
+
end = cleaned.rfind("}")
|
|
391
|
+
if start == -1 or end <= start:
|
|
392
|
+
return None
|
|
393
|
+
candidate = cleaned[start:end + 1]
|
|
394
|
+
try:
|
|
395
|
+
return json.loads(candidate)
|
|
396
|
+
except json.JSONDecodeError:
|
|
397
|
+
pass
|
|
398
|
+
repaired = re.sub(r",\s*([}\]])", r"\1", candidate)
|
|
399
|
+
try:
|
|
400
|
+
return json.loads(repaired)
|
|
401
|
+
except json.JSONDecodeError:
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _circuit_open():
|
|
406
|
+
return time.monotonic() < _circuit_open_until
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _record_failure():
|
|
410
|
+
global _circuit_failures, _circuit_open_until
|
|
411
|
+
_circuit_failures += 1
|
|
412
|
+
if _circuit_failures >= CIRCUIT_THRESHOLD:
|
|
413
|
+
_circuit_open_until = time.monotonic() + CIRCUIT_COOLDOWN
|
|
414
|
+
_logger.error(
|
|
415
|
+
"Provider circuit opened after %d consecutive failures; "
|
|
416
|
+
"pausing AI calls for %ds", _circuit_failures, int(CIRCUIT_COOLDOWN)
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _record_success():
|
|
421
|
+
global _circuit_failures, _circuit_open_until
|
|
422
|
+
_circuit_failures = 0
|
|
423
|
+
_circuit_open_until = 0.0
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _retry_delay(attempt, deadline):
|
|
427
|
+
"""Backoff with jitter, never sleeping past the total retry budget."""
|
|
428
|
+
remaining = deadline - time.monotonic()
|
|
429
|
+
if remaining <= 0:
|
|
430
|
+
return 0.0
|
|
431
|
+
delay = min(RETRY_MAX_DELAY, RETRY_BASE_DELAY * (2 ** attempt))
|
|
432
|
+
delay = min(delay, remaining)
|
|
433
|
+
jitter_cap = min(0.1, max(0.0, remaining - delay))
|
|
434
|
+
return delay + random.uniform(0, jitter_cap)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _invoke_with_deadline(call_fn, prompt, timeout):
|
|
438
|
+
"""Run a provider call in a worker thread and abandon it after `timeout`.
|
|
439
|
+
|
|
440
|
+
The provider SDK's own timeout is not always honoured (a hung TCP
|
|
441
|
+
connection can block past it), and the retry ladder cannot help while the
|
|
442
|
+
first call is still running. Bounding the wall clock here keeps every
|
|
443
|
+
agent's worst case at `(MAX_RETRIES + 1) * timeout`.
|
|
444
|
+
"""
|
|
445
|
+
if prompt is not None:
|
|
446
|
+
def _run():
|
|
447
|
+
return call_fn(prompt)
|
|
448
|
+
else:
|
|
449
|
+
def _run():
|
|
450
|
+
return call_fn()
|
|
451
|
+
|
|
452
|
+
executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
|
453
|
+
future = executor.submit(_run)
|
|
454
|
+
try:
|
|
455
|
+
return future.result(timeout=timeout)
|
|
456
|
+
except concurrent.futures.TimeoutError:
|
|
457
|
+
_logger.error("provider call exceeded %.0fs; abandoning attempt", timeout)
|
|
458
|
+
raise TimeoutError("provider call exceeded {:.0f}s".format(timeout))
|
|
459
|
+
finally:
|
|
460
|
+
try:
|
|
461
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
462
|
+
except TypeError: # Python < 3.9 has no cancel_futures
|
|
463
|
+
executor.shutdown(wait=False)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _call_with_retry(call_fn, prompt, agent_name, timeout):
|
|
467
|
+
"""Shared retry + JSON parse logic for all providers.
|
|
468
|
+
|
|
469
|
+
Retries on transport errors *and* malformed JSON, with capped backoff and
|
|
470
|
+
a circuit breaker. Always returns a dict; failures carry `_failed`.
|
|
471
|
+
"""
|
|
472
|
+
deadline = time.monotonic() + RETRY_BUDGET
|
|
473
|
+
last_error = "unknown error"
|
|
474
|
+
error_kind = "transport"
|
|
475
|
+
attempts = 0
|
|
476
|
+
|
|
477
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
478
|
+
attempts += 1
|
|
479
|
+
if _circuit_open():
|
|
480
|
+
_logger.error("%s skipped: provider circuit is open", agent_name)
|
|
481
|
+
return {"findings": [], "summary": "Provider temporarily unavailable",
|
|
482
|
+
"_failed": True, "_error": "provider circuit is open",
|
|
483
|
+
"_error_kind": "circuit_open"}
|
|
484
|
+
|
|
485
|
+
attempt_timeout = max(1.0, timeout or API_TIMEOUT)
|
|
486
|
+
try:
|
|
487
|
+
text = _invoke_with_deadline(call_fn, prompt, attempt_timeout)
|
|
488
|
+
data = _parse_json_response(text)
|
|
489
|
+
if data is not None:
|
|
490
|
+
_record_success()
|
|
491
|
+
return data
|
|
492
|
+
last_error = "model did not return valid JSON"
|
|
493
|
+
error_kind = "invalid_json"
|
|
494
|
+
except Exception as exc:
|
|
495
|
+
last_error = str(exc)
|
|
496
|
+
error_kind = "transport"
|
|
497
|
+
|
|
498
|
+
if attempt >= MAX_RETRIES:
|
|
499
|
+
break
|
|
500
|
+
|
|
501
|
+
delay = _retry_delay(attempt, deadline)
|
|
502
|
+
if delay <= 0:
|
|
503
|
+
_logger.warning("%s retry budget exhausted after %s", agent_name, last_error)
|
|
504
|
+
break
|
|
505
|
+
_logger.warning(
|
|
506
|
+
"%s attempt %d/%d failed: %s. Retrying in %.1fs...",
|
|
507
|
+
agent_name, attempt + 1, MAX_RETRIES + 1, last_error, delay
|
|
508
|
+
)
|
|
509
|
+
time.sleep(delay)
|
|
510
|
+
|
|
511
|
+
_record_failure()
|
|
512
|
+
message = "API error after {} attempt(s): {}".format(attempts, last_error)
|
|
513
|
+
return {"findings": [], "summary": message, "_failed": True, "_error": message,
|
|
514
|
+
"_error_kind": error_kind}
|
prism/ai_quality.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AI Quality Agent - Uses Gemini for code quality review.
|
|
3
|
+
|
|
4
|
+
Catches: code complexity, duplication, maintainability issues, naming problems,
|
|
5
|
+
dead code, poor architecture, and technical debt indicators.
|
|
6
|
+
|
|
7
|
+
Rule IDs follow SonarQube convention: QUAL-XXX
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import List
|
|
12
|
+
from prism.agents import BaseAgent, Finding, register_agent
|
|
13
|
+
from prism.ai_common import review_with_agent
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
SYSTEM_PROMPT = """
|
|
18
|
+
You are a senior software engineer with expertise in code quality and maintainability.
|
|
19
|
+
You review code for quality issues that automated tools and pattern matching cannot detect.
|
|
20
|
+
|
|
21
|
+
Focus on:
|
|
22
|
+
1. Code complexity (deep nesting, long functions, high cyclomatic complexity)
|
|
23
|
+
2. Code duplication (repeated logic that should be extracted)
|
|
24
|
+
3. Maintainability issues (hardcoded values, magic numbers, unclear intent)
|
|
25
|
+
4. Naming problems (confusing variable names, inconsistent conventions)
|
|
26
|
+
5. Dead code (unused functions, unreachable code, commented-out blocks)
|
|
27
|
+
6. Poor architecture (tight coupling, missing abstraction, God functions)
|
|
28
|
+
7. Technical debt markers (TODO, FIXME, HACK, XXX)
|
|
29
|
+
8. Inconsistent style and formatting that hurts readability
|
|
30
|
+
|
|
31
|
+
Rules:
|
|
32
|
+
1. Return ONLY valid JSON. No markdown, no extra text.
|
|
33
|
+
2. Be specific. Each finding must include: severity, category, message, file, line, suggestion, suggested_code.
|
|
34
|
+
3. For suggested_code, provide the actual code that fixes the issue.
|
|
35
|
+
4. Ignore minor style preferences. Focus on issues that actually affect maintainability.
|
|
36
|
+
5. If you find no issues, return empty findings array.
|
|
37
|
+
|
|
38
|
+
Schema:
|
|
39
|
+
{
|
|
40
|
+
"findings": [
|
|
41
|
+
{
|
|
42
|
+
"severity": "critical" | "high" | "medium" | "low",
|
|
43
|
+
"category": "string",
|
|
44
|
+
"message": "string",
|
|
45
|
+
"file": "string",
|
|
46
|
+
"line": number,
|
|
47
|
+
"suggestion": "string",
|
|
48
|
+
"suggested_code": "string or null"
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"summary": "2-3 sentence overall quality assessment"
|
|
52
|
+
}
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@register_agent
|
|
57
|
+
class AIQualityAgent(BaseAgent):
|
|
58
|
+
name = "ai_quality"
|
|
59
|
+
description = "AI-powered code quality reviewer using Gemini"
|
|
60
|
+
|
|
61
|
+
def _check(self, file_path: str, content: str, diff: str,
|
|
62
|
+
skeleton: str = "", line_count: int = None,
|
|
63
|
+
fast: bool = False) -> List[Finding]:
|
|
64
|
+
return review_with_agent(SYSTEM_PROMPT, file_path, content,
|
|
65
|
+
self.name, skeleton, line_count, diff, fast)
|
prism/ai_security.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AI Security Agent - Uses Gemini for security code review.
|
|
3
|
+
|
|
4
|
+
Catches: hardcoded secrets, injection vulnerabilities, authentication flaws,
|
|
5
|
+
authorization issues, cryptographic weaknesses, and insecure dependencies.
|
|
6
|
+
|
|
7
|
+
Rule IDs follow SonarQube convention: SECU-XXX
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import List
|
|
12
|
+
from prism.agents import BaseAgent, Finding, register_agent
|
|
13
|
+
from prism.ai_common import review_with_agent
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
SYSTEM_PROMPT = """
|
|
18
|
+
You are an expert application security engineer with 15+ years of experience.
|
|
19
|
+
You review code for security vulnerabilities that pattern matching cannot detect.
|
|
20
|
+
|
|
21
|
+
Focus on:
|
|
22
|
+
1. Hardcoded secrets (API keys, passwords, tokens, private keys)
|
|
23
|
+
2. Injection vulnerabilities (SQL, command, XSS, LDAP, template)
|
|
24
|
+
3. Authentication and authorization flaws
|
|
25
|
+
4. Cryptographic weaknesses (weak algorithms, hardcoded salts, insecure randomness)
|
|
26
|
+
5. Insecure dependencies and known CVE patterns
|
|
27
|
+
6. Data exposure (sensitive data in logs, error messages, URLs)
|
|
28
|
+
7. Race conditions and concurrency issues
|
|
29
|
+
8. Insecure deserialization
|
|
30
|
+
|
|
31
|
+
Rules:
|
|
32
|
+
1. Return ONLY valid JSON. No markdown, no extra text.
|
|
33
|
+
2. Be specific. Each finding must include: severity, category, message, file, line, suggestion, suggested_code.
|
|
34
|
+
3. For suggested_code, provide the actual code that fixes the issue.
|
|
35
|
+
4. Ignore style, formatting, naming conventions, and minor code cleanliness.
|
|
36
|
+
5. If you find no issues, return empty findings array.
|
|
37
|
+
|
|
38
|
+
Schema:
|
|
39
|
+
{
|
|
40
|
+
"findings": [
|
|
41
|
+
{
|
|
42
|
+
"severity": "critical" | "high" | "medium" | "low",
|
|
43
|
+
"category": "string",
|
|
44
|
+
"message": "string",
|
|
45
|
+
"file": "string",
|
|
46
|
+
"line": number,
|
|
47
|
+
"suggestion": "string",
|
|
48
|
+
"suggested_code": "string or null"
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"summary": "2-3 sentence overall security assessment"
|
|
52
|
+
}
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@register_agent
|
|
57
|
+
class AISecurityAgent(BaseAgent):
|
|
58
|
+
name = "ai_security"
|
|
59
|
+
description = "AI-powered security reviewer using Gemini"
|
|
60
|
+
|
|
61
|
+
def _check(self, file_path: str, content: str, diff: str,
|
|
62
|
+
skeleton: str = "", line_count: int = None,
|
|
63
|
+
fast: bool = False) -> List[Finding]:
|
|
64
|
+
return review_with_agent(SYSTEM_PROMPT, file_path, content,
|
|
65
|
+
self.name, skeleton, line_count, diff, fast)
|