Inside Gemini Code Reviewer: Building an AI-Powered Python Code Critic with Flask and Gemini 1.5 Flash
A deep dive into azario0's gemini-code-reviewer — a Flask app that sends your Python code to Google's Gemini API and returns a rating, a written critique, and an improved version, plus the Jupyter notebook where the prompt and JSON-cleaning logic were prototyped.
Inside Gemini Code Reviewer: Building an AI-Powered Python Code Critic with Flask and Gemini 1.5 Flash
If you’ve ever wanted a second pair of eyes on your Python before shipping it, azario0/gemini-code-reviewer is a small but well-structured example of how to wrap a large language model in a usable developer tool. It’s a Flask web app that takes a snippet of Python, sends it to Google’s Gemini API with a carefully engineered prompt, and renders back a 1–5 rating, a written critique, and a rewritten version of the code — all in a clean Bootstrap-styled dark UI.
This article walks through the entire codebase, including the accompanying notebook.ipynb, which is where the author actually prototyped the prompt and the JSON-cleaning logic before turning it into a Flask route.
Note: I don’t have live web access — I cloned and read the repository directly, so the file contents below reflect what’s actually in the repo at the time of writing. Worth a quick diff-check if the project has moved on since.
Project layout
gemini-code-reviewer/
├── README.md
├── notebook.ipynb
└── code-reviewer-app/
├── app.py
├── requirements.txt
├── static/
│ └── style.css
└── templates/
└── index.html
Three moving pieces: a research notebook, a Flask backend, and a static/templates pair for the frontend. Nothing fancy — no database, no auth, no build step. That simplicity is actually the point: it’s a good reference for “how do I put an LLM behind a form” without any framework noise getting in the way.
Tech stack
- Backend: Flask 3.x
- AI model:
google-generativeaiSDK, callinggemini-1.5-flash - Frontend: server-rendered Jinja2 HTML + vanilla JavaScript (
fetch) + Bootstrap 5.3.3 via CDN - Config:
python-dotenvfor loading the API key from a.envfile - Fonts: Google Fonts (
Interfor body text,Fira Codefor the code editor textarea)
See requirements.txt — it’s deliberately minimal: Flask, google-generativeai, python-dotenv.
The notebook: where the design actually happened
Before there was an app.py, there was notebook.ipynb. This is the scratchpad where the author worked out two things that later got copy-pasted almost verbatim into the Flask app: the prompt and the JSON-cleaning function.
The notebook cells, in order:
- Model setup / smoke test — configures
genaiwith an API key and instantiatesgemini-1.5-flash, then does a throwawaygenerate_contentcall (a poem about the ocean) just to confirm the SDK and key work. - The actual review prompt, tested against a trivial example:
sent through a prompt that instructs Gemini to act as “a code reviewer and performance optimizer” and to focus on correctness, performance, and readability — then to respond only in JSON withcode = """ counter = 0 for i in range(1): counter += 5 print(counter) """Rating,Remark, andSuggestedVersionkeys. clean_llm_json()— a defensive post-processing function (details below) that strips Markdown fences off the model’s response and parses it into real JSON, because LLMs reliably wrap JSON in```json ... ```blocks even when told not to.- A print of the cleaned result, confirming the model correctly flagged the pointless
for i in range(1)loop and rated the snippet 3/5. - An unrelated scratch cell — a manual bubble/selection-sort implementation on a small list, seemingly left over from testing, not wired into the app.
The notebook is worth reading not for the app’s logic (which is short) but for the iteration story: it shows the actual prompt-engineering loop — write a prompt, run it against a deliberately bad snippet, inspect the raw output, notice it’s wrapped in fences, write a cleaner, re-run. That’s the real “methodology” this repo demonstrates, more than the Flask code itself.
The backend: app.py
app.py is about 120 lines and does four things.
1. Model configuration at import time
load_dotenv()
app = Flask(__name__)
try:
api_key = os.getenv("GOOGLE_API_KEY")
if not api_key:
raise ValueError("GOOGLE_API_KEY not found in .env file.")
genai.configure(api_key=api_key)
model = genai.GenerativeModel("gemini-1.5-flash")
except Exception as e:
print(f"Error configuring Generative AI: {e}")
model = None
This runs once, at module load, not per-request. If the key is missing or configuration fails, model stays None rather than crashing the whole app — the /review route checks for that and returns a 500 with a clear message instead of throwing an unhandled exception. It’s a small but sensible resilience pattern: fail soft at startup, fail loud (but controlled) at request time.
2. clean_llm_json() — ported straight from the notebook
def clean_llm_json(raw_json: str) -> str:
try:
raw_json = re.sub(r"^```json\s*|\s*```$", "", raw_json.strip(), flags=re.DOTALL)
data = json.loads(raw_json)
if "Remark" in data and isinstance(data["Remark"], str):
remark = data["Remark"]
remark = re.sub(r'\s+', ' ', remark).strip()
data["Remark"] = remark
if "SuggestedVersion" in data and isinstance(data["SuggestedVersion"], str):
data["SuggestedVersion"] = data["SuggestedVersion"].strip()
return json.dumps(data, indent=2, ensure_ascii=False)
except Exception as e:
error_response = {
"error": "Failed to parse LLM response",
"details": str(e),
"raw_response": raw_json
}
return json.dumps(error_response, indent=2)
This is the most instructive function in the repo, because it’s a concrete answer to a problem everyone hits when building on top of LLM output: the model doesn’t reliably follow “respond only in JSON.” In practice Gemini (like most chat-tuned LLMs) tends to wrap structured output in triple-backtick fences (```json ... ```), even under explicit instruction not to. Rather than fight the model with prompt tweaks alone, the author treats it as a parsing problem:
- Strip a leading
```jsonand trailing```with a regex anchored to the start/end of the string. json.loads()the remainder.- Normalize whitespace in the
Remarkfield (collapsing newlines/multiple spaces — LLMs love to over-format free text). - Re-serialize with
json.dumps(..., indent=2, ensure_ascii=False)for a clean, pretty, Unicode-safe string. - On any failure, return a structured error object instead of raising — so the caller (the Flask route) always gets something JSON-shaped back, error or not.
This “always return valid JSON, even for failures” discipline is a good pattern to borrow whenever you’re gluing an LLM’s free-text output into a typed API contract.
3. Routes
@app.route('/')
def index():
return render_template('index.html')
@app.route('/review', methods=['POST'])
def review_code():
if not model:
return jsonify({"error": "Generative AI model is not configured."}), 500
code_to_review = request.json.get('code')
if not code_to_review:
return jsonify({"error": "No code provided."}), 400
prompt = f"""...""" # same prompt as the notebook
try:
response = model.generate_content(prompt)
cleaned_json_str = clean_llm_json(response.text)
json_data = json.loads(cleaned_json_str)
return jsonify(json_data)
except Exception as e:
return jsonify({"error": f"An error occurred while generating content: {e}"}), 500
Two routes, both minimal:
GET /just renders the template — no server-side state.POST /reviewis the whole app: validate the model is configured, validate the request body has acodefield, build the same prompt from the notebook (f-string interpolating the user’s code into a fenced Python block), callmodel.generate_content(prompt), run the result throughclean_llm_json, parse that back into a Python dict, and return it viajsonify.
One subtlety worth flagging for anyone adapting this: clean_llm_json already returns a JSON string, and the route immediately does json.loads() on it again before calling jsonify(). That’s a slightly redundant round-trip (string → dict → string → dict → string again on the wire) but it’s harmless and keeps the notebook function reusable as-is elsewhere.
4. The prompt itself
You are a code reviewer and performance optimizer.
Your task is to analyze the given code and provide constructive feedback.
Focus on:
- Correctness (does the code work as intended?)
- Performance (can it run faster or use fewer resources?)
- Readability and best practices (is it easy to understand and maintain?)
Here is the code to review:
```python
{code_to_review}
Respond ONLY in JSON format with the following fields:
{ “Rating”: “An integer from 1 (poor) to 5 (excellent)”, “Remark”: “A constructive explanation that highlights strengths, points out issues, and suggests improvements”, “SuggestedVersion”: “An improved version of the given code (if possible).” }
This is a textbook example of structured-output prompting: role assignment ("You are a code reviewer..."), an explicit evaluation rubric (three named axes), the input clearly delimited in a fenced code block, and a literal JSON schema the model is told to mirror. It works reasonably well for a fast, cheap model like `gemini-1.5-flash` precisely because the schema is small and the fields are unambiguous — which is also why `clean_llm_json` only needs to guard against fence-wrapping rather than more exotic malformed output.
## The frontend: `index.html` + `style.css`
[`templates/index.html`](https://github.com/azario0/gemini-code-reviewer/blob/main/code-reviewer-app/templates/index.html) is a single Jinja2 template — there's no templating logic beyond `{{ url_for('static', filename='style.css') }}`, so effectively it's static HTML plus one inline `<script>` block. Structure:
- A `<textarea id="codeInput">` for pasting code, styled as a monospace "editor" via [`static/style.css`](https://github.com/azario0/gemini-code-reviewer/blob/main/code-reviewer-app/static/style.css) (`Fira Code`, dark background, light text).
- A submit button with a hidden Bootstrap spinner (`.spinner-border.d-none`) that's toggled visible during the request.
- A results card (`#resultsContainer`, initially `d-none`) with slots for `#rating`, `#remark`, and `#suggestedCode`.
- A separate error alert (`#errorContainer`) for failure states.
The JavaScript is a single `async` click handler with no framework:
```javascript
const response = await fetch('/review', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: code })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Server responded with status ${response.status}`);
}
const data = await response.json();
document.getElementById('rating').textContent = `${data.Rating || 'N/A'} / 5`;
document.getElementById('remark').textContent = data.Remark || 'No remark provided.';
document.getElementById('suggestedCode').textContent = data.SuggestedVersion || '# No suggestion provided.';
It’s a clean example of the “loading → success/error → reset” UI state machine using nothing but classList.add/remove('d-none') and a try/catch/finally. No syntax highlighting is applied to the suggested code (it’s rendered into a plain <pre><code>), which the project’s own README lists as a known future improvement — a natural next step would be wiring in Prism.js or highlight.js.
The theming itself leans entirely on Bootstrap’s built-in dark mode (data-bs-theme="dark" on <html>) plus a very small custom stylesheet — about 30 lines — for the code editor look and card hover glow.
How the pieces fit together end to end
- User loads
/, gets the renderedindex.html. - User pastes Python into the textarea and clicks Review My Code.
- Client-side JS
POSTs{ "code": "..." }to/review. - Flask’s
review_code()validates input, builds the review prompt (identical to the one prototyped in the notebook), and callsmodel.generate_content(prompt). - Gemini returns text that’s supposed to be pure JSON but is usually fenced.
clean_llm_json()strips the fences, parses, normalizes whitespace, and re-serializes.- The route parses that string once more and returns it via
jsonify(). - The browser reads
Rating,Remark, andSuggestedVersionoff the response and paints them into the results card.
What this project is a good reference for
- A minimal, honest example of turning notebook-prototyped prompt engineering into a production route without over-engineering it.
- A reusable pattern (
clean_llm_json) for defensively parsing LLM output that’s supposed to be JSON but might arrive Markdown-fenced. - A no-framework frontend that talks to a Flask JSON API with plain
fetch, useful as a template for small internal tools. - A jumping-off point for the improvements the README itself flags as future work — syntax highlighting, multi-language support, a diff viewer, Dockerization, and persisted history via something like SQLite.
The full source, including the license (MIT), is on GitHub at azario0/gemini-code-reviewer.
Written by
Benmalek Zohir
Founder, AI Engineer & Full Stack Developer
Benmalek Zohir is an AI Engineer, Full Stack Developer, and technology enthusiast focused on artificial intelligence, software development, and emerging technologies. He is the founder of SoftwareJournal.blog, where he shares practical insights, software discoveries, AI tools, and the latest developments in technology.