Coverage for vibefpmgui/ai_review.py: 89%
303 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""AI code review with a local model served by Ollama (https://ollama.com).
3Everything runs on this machine: the GUI talks to an ``ollama serve``
4process on localhost (starting one if needed), so no code leaves the
5computer. Models are downloaded once with ``ollama pull`` (the GUI can do
6that too) and are then available offline.
8The model is asked to answer with one finding per line::
10 LINE 12: bug: the loop never updates i
11 LINE 30-34: style: use an assumed-shape array instead of (*)
13followed by ``SUMMARY: ...``. `parse_findings` reads those lines back
14(tolerating the small variations models produce), so findings can be listed
15and marked in the editor's gutter.
17The work happens in a `ReviewWorker` on a background thread; the
18``ollama`` Python package is optional and imported lazily.
19"""
21from __future__ import annotations
23import contextlib
24import re
25import shutil
26import threading
27import time
28from dataclasses import dataclass
30from qtpy.QtCore import QObject, QThread, Signal, Slot
32from .ai_edits import END_RE as EDIT_END_RE
33from .ai_edits import START_RE as EDIT_START_RE
35DEFAULT_MODEL = "qwen2.5-coder:7b"
36MAX_ANSWER_TOKENS = 1500 # the default answer length: plenty for a list of findings; stops runaway answers
37ANSWER_LENGTHS = (1500, 4000, 8000, 16000, 0) # AI → Answer Length (0: no limit)
38REASONING_TOKENS = 8192 # room for a thinking model's reasoning (AI → Think Before Answering), on top
39DEFAULT_HOST = None # the ollama library's default: $OLLAMA_HOST or localhost:11434
40CATEGORIES = ("bug", "warning", "performance", "style", "suggestion")
42FOCUS = {
43 "general": "Give a balanced review: correctness first, then clarity, performance and style.",
44 "bugs": "Concentrate on real bugs: wrong results, out-of-bounds access, uninitialised "
45 "variables, integer overflow, wrong intent, aliasing, precision loss, race conditions.",
46 "modern": "Concentrate on modernising the code: implicit none, intent, modules instead of "
47 "common blocks/include, assumed-shape arrays, allocatable instead of pointers, "
48 "do concurrent where safe, removing obsolescent features (goto, arithmetic if, "
49 "fixed form, equivalence).",
50 "performance": "Concentrate on performance: memory access order (Fortran is column-major), "
51 "unnecessary temporaries and copies, repeated allocation in loops, "
52 "vectorisation and OpenMP opportunities.",
53 "docs": "Concentrate on documentation and readability: missing or misleading comments, "
54 "unclear names, FORD-style (!>) doc comments for public procedures.",
55}
56FOCUS_LABELS = {"general": "General", "bugs": "Bugs", "modern": "Modern Fortran style",
57 "performance": "Performance", "docs": "Documentation"}
59SYSTEM_PROMPT = """You are an experienced reviewer of scientific and numerical code, \
60expert in modern Fortran (2008/2018), and also C and Python. You review one file at a time.
62Rules for your answer:
63- Write one line per finding, in exactly this form:
64 LINE <n>: <category>: <comment>
65 or, for a range of lines,
66 LINE <start>-<end>: <category>: <comment>
67- <category> is one of: bug, warning, performance, style, suggestion.
68- Use the line numbers shown at the start of each source line.
69- Keep each comment to one or two sentences and say how to fix it.
70- Only report real, specific issues. Do not invent problems; if the code is fine, say so.
71- After the findings, write a line starting with "SUMMARY:" with a one to three
72 sentence overall assessment.
73- Do not repeat the code and do not use Markdown headings or code blocks."""
76@dataclass
77class Finding:
78 line: int
79 end_line: int
80 category: str
81 comment: str
84FINDING_RE = re.compile(
85 r"""^\s*(?:[-*•]\s*|\d+[.)]\s*)? # optional bullet / numbering
86 \**\s*L(?:INE)?S?\s*\#?\s*(\d+) # LINE 12 / Line 12 / L12
87 (?:\s*(?:-|–|to)\s*(\d+))? # optional range
88 \s*\**\s*[:\])-]\s*\**\s* # separator
89 (?:(bug|error|warning|performance|style|suggestion|info|note)s?\**\s*[:\-]\s*)?
90 (.+?)\s*$""",
91 re.IGNORECASE | re.VERBOSE)
92SUMMARY_RE = re.compile(r"^\s*\**\s*SUMMARY\s*\**\s*:\s*(.*)$", re.IGNORECASE)
95def parse_findings(text: str, max_line: int | None = None) -> tuple[list[Finding], str]:
96 """Findings and the summary from a model's answer."""
97 findings: list[Finding] = []
98 summary_lines: list[str] = []
99 in_summary = False
100 for raw in text.splitlines():
101 if in_summary: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true
102 if raw.strip():
103 summary_lines.append(raw.strip())
104 continue
105 match = SUMMARY_RE.match(raw)
106 if match:
107 in_summary = True
108 if match.group(1).strip(): 108 ↛ 110line 108 didn't jump to line 110 because the condition on line 108 was always true
109 summary_lines.append(match.group(1).strip())
110 continue
111 match = FINDING_RE.match(raw)
112 if not match:
113 # Models often explain a finding on the following line(s).
114 extra = raw.strip().lstrip("-*\u2022 ").strip()
115 if extra and findings and not extra.startswith("```"):
116 last = findings[-1]
117 joiner = " " if last.comment.rstrip("\x00").endswith((".", "!", "?", ":")) else " — "
118 last.comment = f"{last.comment}{joiner}{extra}"
119 elif not extra and findings:
120 findings[-1].comment += "\x00" # a blank line ends the explanation
121 continue
122 start = int(match.group(1))
123 end = int(match.group(2)) if match.group(2) else start
124 if end < start: 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true
125 start, end = end, start
126 if max_line is not None and (start < 1 or start > max_line):
127 continue # a hallucinated line number
128 category = (match.group(3) or "suggestion").lower()
129 category = {"error": "bug", "info": "suggestion", "note": "suggestion"}.get(category, category)
130 comment = match.group(4).strip().strip("*").strip()
131 if comment: 131 ↛ 100line 131 didn't jump to line 100 because the condition on line 131 was always true
132 findings.append(Finding(start, min(end, max_line or end), category, comment))
133 unique: list[Finding] = []
134 seen = set()
135 for f in findings:
136 # Text after a blank line wasn't part of the finding.
137 f.comment = f.comment.split("\x00")[0].strip()
138 key = (f.line, f.end_line, f.category, " ".join(f.comment.lower().split()))
139 if key not in seen: # small models sometimes repeat themselves
140 seen.add(key)
141 unique.append(f)
142 return unique, " ".join(summary_lines)
145class RepeatGuard:
146 """Spots a model stuck in a loop (writing a line it already wrote).
148 Text is passed on a line at a time, so a repeated line can be held back:
149 `feed` returns the text that's safe to show and whether to stop.
151 Lines in code (``` blocks) and in edits (SEARCH/REPLACE blocks) aren't
152 looked at: there, repeating is normal (an edit's new text repeats the
153 unchanged lines of the old one). A loop there still ends at the answer's
154 length limit.
155 """
157 MIN_LENGTH = 12 # short lines ("", "```", "SUMMARY:") may legitimately repeat
159 def __init__(self):
160 self._partial = ""
161 self._seen: set[str] = set()
162 self.repeated = False
163 self._in_code = False # in a ``` block
164 self._in_edit = False # in a SEARCH/REPLACE block
166 @staticmethod
167 def _key(line: str) -> str:
168 return " ".join(line.lower().split())
170 def feed(self, text: str) -> tuple[str, bool]:
171 self._partial += text
172 out = []
173 while "\n" in self._partial:
174 line, self._partial = self._partial.split("\n", 1)
175 key = self._key(line)
176 if EDIT_START_RE.match(line):
177 self._in_edit = True
178 elif EDIT_END_RE.match(line):
179 self._in_edit = False
180 elif line.lstrip().startswith("```"):
181 self._in_code = not self._in_code
182 elif len(key) >= self.MIN_LENGTH and not (self._in_code or self._in_edit):
183 if key in self._seen:
184 self.repeated = True
185 self._partial = ""
186 return "".join(out), True
187 self._seen.add(key)
188 out.append(line + "\n")
189 return "".join(out), False
191 def flush(self) -> str:
192 """Whatever is left at the end (a last line without a newline)."""
193 rest, self._partial = self._partial, ""
194 if rest and len(self._key(rest)) >= self.MIN_LENGTH and self._key(rest) in self._seen \ 194 ↛ 196line 194 didn't jump to line 196 because the condition on line 194 was never true
195 and not (self._in_code or self._in_edit):
196 self.repeated = True
197 return ""
198 return rest
201def build_prompt(path: str, language: str, code: str, focus: str,
202 diagnostics: list[tuple[int, str, str]], first_line: int = 1, total_lines: int = 0) -> str:
203 """The user message: instructions, compiler messages and numbered code.
204 ``code`` can be part of the file: its lines from ``first_line`` (of
205 ``total_lines``), numbered as in the file."""
206 # Split only at newlines, like the editor: str.splitlines() would also
207 # split at form feeds, U+2028 etc. and shift every later line number.
208 lines = code.split("\n")
209 if len(lines) > 1 and lines[-1] == "":
210 lines.pop() # a final newline doesn't start another line
211 last_line = first_line + len(lines) - 1
212 width = len(str(last_line))
213 numbered = "\n".join(f"{i:>{width}}| {line}" for i, line in enumerate(lines, first_line))
214 parts = [f"Review this {language} file: {path}", FOCUS.get(focus, FOCUS["general"])]
215 if total_lines and (first_line > 1 or last_line < total_lines):
216 parts.append(f"Only lines {first_line}–{last_line} of the file's {total_lines} are shown: review "
217 "only these. Don't report problems that may come from code that isn't shown "
218 "(declarations elsewhere in the file, other procedures).")
219 diagnostics = [d for d in diagnostics if first_line <= d[0] <= last_line]
220 if diagnostics:
221 notes = "\n".join(f"line {ln}: {sev}: {msg}" for ln, sev, msg in diagnostics[:40])
222 parts.append("The compiler already reported these messages in the last build, and they "
223 "are shown to the user separately. Do NOT list them again; look for other "
224 "problems the compiler can't see:\n" + notes)
225 parts.append("Source (line numbers on the left):\n" + numbered)
226 return "\n\n".join(parts)
229EXPLAIN_SYSTEM_PROMPT = """You are an expert in modern Fortran, its compilers (gfortran, \
230LFortran, Intel) and linters, helping a programmer understand one message about their code. \
231Answer in Markdown, briefly and concretely, for this code (not in general):
2321. **What it means**: the message in plain words.
2332. **Why here**: what in the marked line (and around it) causes it.
2343. **How to fix it**: the change to make, with the corrected lines in a ```fortran block \
235(code always in fenced blocks labeled with its language, so it can be highlighted).
236If the message is harmless or a style preference, say so. Don't invent code that isn't shown."""
238EXPLAIN_CONTEXT_LINES = 15 # lines of code shown before and after the problem
241def build_explain_prompt(path: str, code: str, line: int, kind: str, message: str,
242 rule: str = "", compiler: str = "") -> str:
243 """The question for one problem: the message, where, and the code around
244 it (numbered, the problem's line marked with >>)."""
245 lines = code.split("\n")
246 first = max(1, line - EXPLAIN_CONTEXT_LINES)
247 last = min(len(lines), line + EXPLAIN_CONTEXT_LINES)
248 width = len(str(last))
249 shown = "\n".join(f"{'>>' if n == line else ' '} {n:>{width}}| {lines[n - 1]}" for n in range(first, last + 1))
250 source = {"build": f"the compiler ({compiler})" if compiler else "the compiler",
251 "fortitude": "the fortitude linter", "fortls": "the fortls language server"}.get(kind, kind)
252 what = f"{rule}: {message}" if rule and rule != kind else message
253 return (f"In {path}, line {line}, {source} reports:\n\n {what}\n\n"
254 f"The code around it (line numbers on the left; the line is marked >>):\n\n{shown}\n\n"
255 "Explain this message for this code, and how to fix it.")
258CHAT_SYSTEM_PROMPT = """You are a helpful assistant for a programmer working on a Fortran \
259project built with fpm (the Fortran Package Manager). You are an expert in modern Fortran \
260(2008/2018), its compilers (gfortran, LFortran, Intel), fpm, and numerical methods. \
261Answer in Markdown, concisely. Put code in fenced blocks labeled with its language \
262(```fortran, ```toml for fpm.toml, ```bash for commands), so it can be highlighted. \
263If you are not sure, say so.
264When the user's file is attached (its lines are numbered on the left) and you point out a \
265problem or a change at specific lines, also write each one on a line of its own, exactly as
266 LINE <n>: <category>: <comment>
267(or LINE <start>-<end>: ...), where <category> is one of: bug, warning, performance, style, \
268suggestion. Those lines are listed for the user and marked in the editor."""
271EDIT_SYSTEM_PROMPT = """You change a programmer's Fortran file as they ask. Reply with:
2721. One or two sentences on what you'll change.
2732. One SEARCH/REPLACE block per change, in exactly this form:
274<<<<<<< SEARCH
275(lines copied exactly from the file)
276=======
277(the new lines)
278>>>>>>> REPLACE
279Rules: change only what was asked; keep everything else exactly as it is (names, \
280comments, formatting, free or fixed form). Copy the SEARCH lines exactly, enough of them \
281(usually 2 to 6) to be found in only one place in the file. Never write "..." or \
282"rest unchanged" inside a block. To delete lines, leave REPLACE empty. If the request is \
283unclear or unsafe, ask instead of editing. Earlier edits in the conversation are marked \
284applied, not applied yet, or rejected: answer the latest request, and don't propose a \
285rejected edit again."""
287EDIT_ANSWER_TOKENS = 4096 # (several blocks, each with the old and new text)
288MAX_CONTEXT = 32768 # the most asked of Ollama (memory: a 7B model at 32k already needs ~6 GB)
291def context_for(tokens: int, cap: int = MAX_CONTEXT, answer: int = MAX_ANSWER_TOKENS) -> int:
292 """A context window for ``tokens`` of conversation plus the answer: a
293 power of two from 4096, at most ``cap``."""
294 size = 4096
295 while size < tokens + answer and size < cap:
296 size *= 2
297 return min(size, cap)
300def model_context_length(model: str, host: str | None = DEFAULT_HOST) -> int | None:
301 """The longest context ``model`` was trained for, as Ollama reports it
302 (None if it can't be asked)."""
303 try:
304 info = make_client(host, timeout=5).show(model)
305 data = getattr(info, "modelinfo", None)
306 if data is None and isinstance(info, dict):
307 data = info.get("model_info") or info.get("modelinfo")
308 for key, value in dict(data or {}).items():
309 if str(key).endswith(".context_length"):
310 return int(value)
311 except Exception: # noqa: BLE001 - not known, then
312 return None
313 return None
316def ollama_available() -> bool:
317 try:
318 import ollama # noqa: F401
319 except ImportError:
320 return False
321 return True
324def ollama_executable() -> str | None:
325 return shutil.which("ollama")
328def make_client(host: str | None = DEFAULT_HOST, timeout: float | None = None):
329 import ollama
330 return ollama.Client(host=host, timeout=timeout)
333def server_running(host: str | None = DEFAULT_HOST) -> bool:
334 try:
335 make_client(host, timeout=2).list()
336 return True
337 except Exception: # noqa: BLE001 - any failure means "not reachable"
338 return False
341def installed_models(host: str | None = DEFAULT_HOST) -> list[str]:
342 try:
343 response = make_client(host, timeout=5).list()
344 except Exception: # noqa: BLE001
345 return []
346 models = getattr(response, "models", None) or response.get("models", [])
347 names = []
348 for m in models:
349 name = getattr(m, "model", None) or (m.get("model") if isinstance(m, dict) else None) \
350 or (m.get("name") if isinstance(m, dict) else None)
351 if name: 351 ↛ 348line 351 didn't jump to line 348 because the condition on line 351 was always true
352 names.append(name)
353 return sorted(names)
356class ReviewWorker(QObject):
357 """Runs a review (or a model download) on a background thread."""
359 chunk = Signal(str) # streamed text of the answer
360 reasoning = Signal(str) # streamed reasoning of a thinking model
361 progress = Signal(str, float) # download status, fraction (-1 = unknown)
362 finished = Signal(str, dict) # full answer, stats
363 failed = Signal(str)
365 def __init__(self, host: str | None, model: str, prompt: str = "", pull_only: bool = False,
366 system: str = SYSTEM_PROMPT, history: list[dict] | None = None,
367 num_ctx: int | None = None, temperature: float = 0.2,
368 num_predict: int = MAX_ANSWER_TOKENS, think: bool = False):
369 """``system``: the model's instructions (a review's, by default;
370 EXPLAIN_SYSTEM_PROMPT to explain one problem, CHAT_SYSTEM_PROMPT to
371 chat, EDIT_SYSTEM_PROMPT for edits); ``history``: the conversation so
372 far, as chat messages (``{"role": "user" | "assistant", "content":
373 ...}``); ``num_ctx``: the context window (by default, from the
374 prompt's and answer's lengths); ``num_predict``: the answer's length
375 (0: no limit); ``think``: let a thinking model reason first (it gets
376 REASONING_TOKENS more room for that)."""
377 super().__init__()
378 self.host, self.model, self.prompt, self.pull_only = host, model, prompt, pull_only
379 self.system = system
380 self.history = list(history or [])
381 self.num_ctx, self.temperature, self.num_predict, self.think = num_ctx, temperature, num_predict, think
382 self._cancel = threading.Event()
383 self._client = None
385 @property
386 def limit(self) -> int:
387 """The tokens the model may write (reasoning included); 0: no limit."""
388 if self.num_predict <= 0:
389 return 0
390 return self.num_predict + (REASONING_TOKENS if self.think else 0)
392 def cancel(self):
393 """Stop (from the GUI thread). Closing the connection also stops a
394 request that is still waiting for its first answer: the model can
395 take a long time to load and read the prompt, and only then would
396 the stopped flag be looked at. Ollama stops generating when the
397 connection goes away."""
398 self._cancel.set()
399 client = self._client
400 if client is not None: 400 ↛ exitline 400 didn't return from function 'cancel' because the condition on line 400 was always true
401 with contextlib.suppress(Exception): # (already closed: fine)
402 client.close() # (its connection)
404 def _stopped_message(self) -> str:
405 return "Download cancelled" if self.pull_only else "Stopped"
407 @Slot()
408 def run(self):
409 try:
410 self._client = make_client(self.host)
411 if self.pull_only:
412 self._pull(self._client)
413 else:
414 self._review(self._client)
415 except Exception as exc: # noqa: BLE001 - shown to the user
416 if self._cancel.is_set(): # the connection was closed to stop it 416 ↛ 417line 416 didn't jump to line 417 because the condition on line 416 was never true
417 self.failed.emit(self._stopped_message())
418 else:
419 self.failed.emit(str(exc) or type(exc).__name__)
421 def _pull(self, client):
422 for part in client.pull(self.model, stream=True):
423 if self._cancel.is_set(): 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true
424 self.failed.emit(self._stopped_message())
425 return
426 status = getattr(part, "status", "") or ""
427 total = getattr(part, "total", None) or 0
428 completed = getattr(part, "completed", None) or 0
429 self.progress.emit(status, completed / total if total else -1.0)
430 self.finished.emit("", {})
432 def _review(self, client):
433 asked = self.prompt + self.system + "".join(m["content"] for m in self.history)
434 options = {
435 "num_ctx": self.num_ctx or context_for(len(asked) // 3, answer=self.limit or REASONING_TOKENS),
436 "temperature": self.temperature,
437 # Discourage the repetition loops small models fall into, and
438 # cap the answer so a runaway one always ends.
439 "repeat_penalty": 1.1,
440 "repeat_last_n": 512,
441 "num_predict": self.limit or -1, # (-1: no limit)
442 }
443 started = time.monotonic()
444 messages = [{"role": "system", "content": self.system}, *self.history,
445 {"role": "user", "content": self.prompt}]
446 # Thinking models (qwen3, deepseek-r1…) reason at length before they
447 # answer, and that counts against num_predict: unless it's asked for
448 # (with room of its own, see limit), thinking is turned off, which is
449 # also much faster. A model that can't think, or can't stop, says so:
450 # it's asked again as it is.
451 stream = client.chat(model=self.model, stream=True, options=options, messages=messages, think=self.think)
452 parts_iter = iter(stream)
453 try:
454 first = next(parts_iter, None)
455 except Exception as exc:
456 if "think" not in str(exc).lower(): 456 ↛ 457line 456 didn't jump to line 457 because the condition on line 456 was never true
457 raise
458 stream = client.chat(model=self.model, stream=True, options=options, messages=messages)
459 parts_iter = iter(stream)
460 first = next(parts_iter, None)
461 guard = RepeatGuard()
462 parts: list[str] = []
463 stats: dict = {}
464 thought = 0 # characters of reasoning (a model that thinks anyway)
465 try:
466 for part in _chain(first, parts_iter):
467 if self._cancel.is_set():
468 self.failed.emit(self._stopped_message())
469 return
470 message = getattr(part, "message", None)
471 text = getattr(message, "content", "") if message is not None else ""
472 thinking = (getattr(message, "thinking", "") or "") if message is not None else ""
473 if thinking:
474 thought += len(thinking)
475 self.reasoning.emit(thinking)
476 if text:
477 shown, stop = guard.feed(text)
478 if shown:
479 parts.append(shown)
480 self.chunk.emit(shown)
481 if stop:
482 break # closing the stream makes Ollama stop generating
483 if getattr(part, "done", False):
484 stats = {"tokens": getattr(part, "eval_count", 0) or 0,
485 "prompt_tokens": getattr(part, "prompt_eval_count", 0) or 0,
486 "seconds": (getattr(part, "total_duration", 0) or 0) / 1e9}
487 if self.limit and (stats["tokens"] or 0) >= self.limit:
488 stats["truncated"] = True
489 if thought and thought > 3 * sum(map(len, parts)): 489 ↛ 466line 489 didn't jump to line 466 because the condition on line 489 was always true
490 stats["thinking"] = True # (the limit went on reasoning, not the answer)
491 finally:
492 close = getattr(stream, "close", None)
493 if close is not None: 493 ↛ 496line 493 didn't jump to line 496 because the condition on line 493 was always true
494 with contextlib.suppress(Exception): # the connection may be gone (stopped)
495 close()
496 rest = guard.flush()
497 if rest: 497 ↛ 498line 497 didn't jump to line 498 because the condition on line 497 was never true
498 parts.append(rest)
499 self.chunk.emit(rest)
500 if guard.repeated:
501 stats = {"seconds": time.monotonic() - started, "repeated": True}
502 self.chunk.emit("\n[The model started repeating itself, so the review was stopped here.]\n")
503 self.finished.emit("".join(parts), stats)
506def _chain(first, rest):
507 """``first`` (if any), then the rest of an iterator."""
508 if first is not None: 508 ↛ 510line 508 didn't jump to line 510 because the condition on line 508 was always true
509 yield first
510 yield from rest
513def start_worker(worker: ReviewWorker, parent: QObject) -> QThread:
514 """Run ``worker`` on a new thread that cleans itself up when done."""
515 thread = QThread(parent)
516 worker.moveToThread(thread)
517 thread.started.connect(worker.run)
518 worker.finished.connect(thread.quit)
519 worker.failed.connect(thread.quit)
520 thread.finished.connect(worker.deleteLater)
521 thread.finished.connect(thread.deleteLater)
522 thread.start()
523 return thread