vibefpmgui.ai_review
AI code review with a local model served by Ollama (https://ollama.com).
Everything runs on this machine: the GUI talks to an ollama serve
process on localhost (starting one if needed), so no code leaves the
computer. Models are downloaded once with ollama pull (the GUI can do
that too) and are then available offline.
The model is asked to answer with one finding per line::
LINE 12: bug: the loop never updates i
LINE 30-34: style: use an assumed-shape array instead of (*)
followed by SUMMARY: .... parse_findings reads those lines back
(tolerating the small variations models produce), so findings can be listed
and marked in the editor's gutter.
The work happens in a ReviewWorker on a background thread; the
ollama Python package is optional and imported lazily.
1"""AI code review with a local model served by Ollama (https://ollama.com). 2 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. 7 8The model is asked to answer with one finding per line:: 9 10 LINE 12: bug: the loop never updates i 11 LINE 30-34: style: use an assumed-shape array instead of (*) 12 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. 16 17The work happens in a `ReviewWorker` on a background thread; the 18``ollama`` Python package is optional and imported lazily. 19""" 20 21from __future__ import annotations 22 23import contextlib 24import re 25import shutil 26import threading 27import time 28from dataclasses import dataclass 29 30from qtpy.QtCore import QObject, QThread, Signal, Slot 31 32from .ai_edits import END_RE as EDIT_END_RE 33from .ai_edits import START_RE as EDIT_START_RE 34 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") 41 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"} 58 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. 61 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.""" 74 75 76@dataclass 77class Finding: 78 line: int 79 end_line: int 80 category: str 81 comment: str 82 83 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) 93 94 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: 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(): 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: 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: 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) 143 144 145class RepeatGuard: 146 """Spots a model stuck in a loop (writing a line it already wrote). 147 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. 150 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 """ 156 157 MIN_LENGTH = 12 # short lines ("", "```", "SUMMARY:") may legitimately repeat 158 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 165 166 @staticmethod 167 def _key(line: str) -> str: 168 return " ".join(line.lower().split()) 169 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 190 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 \ 195 and not (self._in_code or self._in_edit): 196 self.repeated = True 197 return "" 198 return rest 199 200 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) 227 228 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.""" 237 238EXPLAIN_CONTEXT_LINES = 15 # lines of code shown before and after the problem 239 240 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.") 256 257 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.""" 269 270 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.""" 286 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) 289 290 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) 298 299 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 314 315 316def ollama_available() -> bool: 317 try: 318 import ollama # noqa: F401 319 except ImportError: 320 return False 321 return True 322 323 324def ollama_executable() -> str | None: 325 return shutil.which("ollama") 326 327 328def make_client(host: str | None = DEFAULT_HOST, timeout: float | None = None): 329 import ollama 330 return ollama.Client(host=host, timeout=timeout) 331 332 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 339 340 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: 352 names.append(name) 353 return sorted(names) 354 355 356class ReviewWorker(QObject): 357 """Runs a review (or a model download) on a background thread.""" 358 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) 364 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 384 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) 391 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: 401 with contextlib.suppress(Exception): # (already closed: fine) 402 client.close() # (its connection) 403 404 def _stopped_message(self) -> str: 405 return "Download cancelled" if self.pull_only else "Stopped" 406 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 417 self.failed.emit(self._stopped_message()) 418 else: 419 self.failed.emit(str(exc) or type(exc).__name__) 420 421 def _pull(self, client): 422 for part in client.pull(self.model, stream=True): 423 if self._cancel.is_set(): 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("", {}) 431 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(): 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)): 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: 494 with contextlib.suppress(Exception): # the connection may be gone (stopped) 495 close() 496 rest = guard.flush() 497 if rest: 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) 504 505 506def _chain(first, rest): 507 """``first`` (if any), then the rest of an iterator.""" 508 if first is not None: 509 yield first 510 yield from rest 511 512 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
96def parse_findings(text: str, max_line: int | None = None) -> tuple[list[Finding], str]: 97 """Findings and the summary from a model's answer.""" 98 findings: list[Finding] = [] 99 summary_lines: list[str] = [] 100 in_summary = False 101 for raw in text.splitlines(): 102 if in_summary: 103 if raw.strip(): 104 summary_lines.append(raw.strip()) 105 continue 106 match = SUMMARY_RE.match(raw) 107 if match: 108 in_summary = True 109 if match.group(1).strip(): 110 summary_lines.append(match.group(1).strip()) 111 continue 112 match = FINDING_RE.match(raw) 113 if not match: 114 # Models often explain a finding on the following line(s). 115 extra = raw.strip().lstrip("-*\u2022 ").strip() 116 if extra and findings and not extra.startswith("```"): 117 last = findings[-1] 118 joiner = " " if last.comment.rstrip("\x00").endswith((".", "!", "?", ":")) else " — " 119 last.comment = f"{last.comment}{joiner}{extra}" 120 elif not extra and findings: 121 findings[-1].comment += "\x00" # a blank line ends the explanation 122 continue 123 start = int(match.group(1)) 124 end = int(match.group(2)) if match.group(2) else start 125 if end < start: 126 start, end = end, start 127 if max_line is not None and (start < 1 or start > max_line): 128 continue # a hallucinated line number 129 category = (match.group(3) or "suggestion").lower() 130 category = {"error": "bug", "info": "suggestion", "note": "suggestion"}.get(category, category) 131 comment = match.group(4).strip().strip("*").strip() 132 if comment: 133 findings.append(Finding(start, min(end, max_line or end), category, comment)) 134 unique: list[Finding] = [] 135 seen = set() 136 for f in findings: 137 # Text after a blank line wasn't part of the finding. 138 f.comment = f.comment.split("\x00")[0].strip() 139 key = (f.line, f.end_line, f.category, " ".join(f.comment.lower().split())) 140 if key not in seen: # small models sometimes repeat themselves 141 seen.add(key) 142 unique.append(f) 143 return unique, " ".join(summary_lines)
Findings and the summary from a model's answer.
146class RepeatGuard: 147 """Spots a model stuck in a loop (writing a line it already wrote). 148 149 Text is passed on a line at a time, so a repeated line can be held back: 150 `feed` returns the text that's safe to show and whether to stop. 151 152 Lines in code (``` blocks) and in edits (SEARCH/REPLACE blocks) aren't 153 looked at: there, repeating is normal (an edit's new text repeats the 154 unchanged lines of the old one). A loop there still ends at the answer's 155 length limit. 156 """ 157 158 MIN_LENGTH = 12 # short lines ("", "```", "SUMMARY:") may legitimately repeat 159 160 def __init__(self): 161 self._partial = "" 162 self._seen: set[str] = set() 163 self.repeated = False 164 self._in_code = False # in a ``` block 165 self._in_edit = False # in a SEARCH/REPLACE block 166 167 @staticmethod 168 def _key(line: str) -> str: 169 return " ".join(line.lower().split()) 170 171 def feed(self, text: str) -> tuple[str, bool]: 172 self._partial += text 173 out = [] 174 while "\n" in self._partial: 175 line, self._partial = self._partial.split("\n", 1) 176 key = self._key(line) 177 if EDIT_START_RE.match(line): 178 self._in_edit = True 179 elif EDIT_END_RE.match(line): 180 self._in_edit = False 181 elif line.lstrip().startswith("```"): 182 self._in_code = not self._in_code 183 elif len(key) >= self.MIN_LENGTH and not (self._in_code or self._in_edit): 184 if key in self._seen: 185 self.repeated = True 186 self._partial = "" 187 return "".join(out), True 188 self._seen.add(key) 189 out.append(line + "\n") 190 return "".join(out), False 191 192 def flush(self) -> str: 193 """Whatever is left at the end (a last line without a newline).""" 194 rest, self._partial = self._partial, "" 195 if rest and len(self._key(rest)) >= self.MIN_LENGTH and self._key(rest) in self._seen \ 196 and not (self._in_code or self._in_edit): 197 self.repeated = True 198 return "" 199 return rest
Spots a model stuck in a loop (writing a line it already wrote).
Text is passed on a line at a time, so a repeated line can be held back:
feed returns the text that's safe to show and whether to stop.
Lines in code (``` blocks) and in edits (SEARCH/REPLACE blocks) aren't looked at: there, repeating is normal (an edit's new text repeats the unchanged lines of the old one). A loop there still ends at the answer's length limit.
171 def feed(self, text: str) -> tuple[str, bool]: 172 self._partial += text 173 out = [] 174 while "\n" in self._partial: 175 line, self._partial = self._partial.split("\n", 1) 176 key = self._key(line) 177 if EDIT_START_RE.match(line): 178 self._in_edit = True 179 elif EDIT_END_RE.match(line): 180 self._in_edit = False 181 elif line.lstrip().startswith("```"): 182 self._in_code = not self._in_code 183 elif len(key) >= self.MIN_LENGTH and not (self._in_code or self._in_edit): 184 if key in self._seen: 185 self.repeated = True 186 self._partial = "" 187 return "".join(out), True 188 self._seen.add(key) 189 out.append(line + "\n") 190 return "".join(out), False
192 def flush(self) -> str: 193 """Whatever is left at the end (a last line without a newline).""" 194 rest, self._partial = self._partial, "" 195 if rest and len(self._key(rest)) >= self.MIN_LENGTH and self._key(rest) in self._seen \ 196 and not (self._in_code or self._in_edit): 197 self.repeated = True 198 return "" 199 return rest
Whatever is left at the end (a last line without a newline).
202def build_prompt(path: str, language: str, code: str, focus: str, 203 diagnostics: list[tuple[int, str, str]], first_line: int = 1, total_lines: int = 0) -> str: 204 """The user message: instructions, compiler messages and numbered code. 205 ``code`` can be part of the file: its lines from ``first_line`` (of 206 ``total_lines``), numbered as in the file.""" 207 # Split only at newlines, like the editor: str.splitlines() would also 208 # split at form feeds, U+2028 etc. and shift every later line number. 209 lines = code.split("\n") 210 if len(lines) > 1 and lines[-1] == "": 211 lines.pop() # a final newline doesn't start another line 212 last_line = first_line + len(lines) - 1 213 width = len(str(last_line)) 214 numbered = "\n".join(f"{i:>{width}}| {line}" for i, line in enumerate(lines, first_line)) 215 parts = [f"Review this {language} file: {path}", FOCUS.get(focus, FOCUS["general"])] 216 if total_lines and (first_line > 1 or last_line < total_lines): 217 parts.append(f"Only lines {first_line}–{last_line} of the file's {total_lines} are shown: review " 218 "only these. Don't report problems that may come from code that isn't shown " 219 "(declarations elsewhere in the file, other procedures).") 220 diagnostics = [d for d in diagnostics if first_line <= d[0] <= last_line] 221 if diagnostics: 222 notes = "\n".join(f"line {ln}: {sev}: {msg}" for ln, sev, msg in diagnostics[:40]) 223 parts.append("The compiler already reported these messages in the last build, and they " 224 "are shown to the user separately. Do NOT list them again; look for other " 225 "problems the compiler can't see:\n" + notes) 226 parts.append("Source (line numbers on the left):\n" + numbered) 227 return "\n\n".join(parts)
The user message: instructions, compiler messages and numbered code.
code can be part of the file: its lines from first_line (of
total_lines), numbered as in the file.
242def build_explain_prompt(path: str, code: str, line: int, kind: str, message: str, 243 rule: str = "", compiler: str = "") -> str: 244 """The question for one problem: the message, where, and the code around 245 it (numbered, the problem's line marked with >>).""" 246 lines = code.split("\n") 247 first = max(1, line - EXPLAIN_CONTEXT_LINES) 248 last = min(len(lines), line + EXPLAIN_CONTEXT_LINES) 249 width = len(str(last)) 250 shown = "\n".join(f"{'>>' if n == line else ' '} {n:>{width}}| {lines[n - 1]}" for n in range(first, last + 1)) 251 source = {"build": f"the compiler ({compiler})" if compiler else "the compiler", 252 "fortitude": "the fortitude linter", "fortls": "the fortls language server"}.get(kind, kind) 253 what = f"{rule}: {message}" if rule and rule != kind else message 254 return (f"In {path}, line {line}, {source} reports:\n\n {what}\n\n" 255 f"The code around it (line numbers on the left; the line is marked >>):\n\n{shown}\n\n" 256 "Explain this message for this code, and how to fix it.")
The question for one problem: the message, where, and the code around it (numbered, the problem's line marked with >>).
292def context_for(tokens: int, cap: int = MAX_CONTEXT, answer: int = MAX_ANSWER_TOKENS) -> int: 293 """A context window for ``tokens`` of conversation plus the answer: a 294 power of two from 4096, at most ``cap``.""" 295 size = 4096 296 while size < tokens + answer and size < cap: 297 size *= 2 298 return min(size, cap)
A context window for tokens of conversation plus the answer: a
power of two from 4096, at most cap.
301def model_context_length(model: str, host: str | None = DEFAULT_HOST) -> int | None: 302 """The longest context ``model`` was trained for, as Ollama reports it 303 (None if it can't be asked).""" 304 try: 305 info = make_client(host, timeout=5).show(model) 306 data = getattr(info, "modelinfo", None) 307 if data is None and isinstance(info, dict): 308 data = info.get("model_info") or info.get("modelinfo") 309 for key, value in dict(data or {}).items(): 310 if str(key).endswith(".context_length"): 311 return int(value) 312 except Exception: # noqa: BLE001 - not known, then 313 return None 314 return None
The longest context model was trained for, as Ollama reports it
(None if it can't be asked).
342def installed_models(host: str | None = DEFAULT_HOST) -> list[str]: 343 try: 344 response = make_client(host, timeout=5).list() 345 except Exception: # noqa: BLE001 346 return [] 347 models = getattr(response, "models", None) or response.get("models", []) 348 names = [] 349 for m in models: 350 name = getattr(m, "model", None) or (m.get("model") if isinstance(m, dict) else None) \ 351 or (m.get("name") if isinstance(m, dict) else None) 352 if name: 353 names.append(name) 354 return sorted(names)
357class ReviewWorker(QObject): 358 """Runs a review (or a model download) on a background thread.""" 359 360 chunk = Signal(str) # streamed text of the answer 361 reasoning = Signal(str) # streamed reasoning of a thinking model 362 progress = Signal(str, float) # download status, fraction (-1 = unknown) 363 finished = Signal(str, dict) # full answer, stats 364 failed = Signal(str) 365 366 def __init__(self, host: str | None, model: str, prompt: str = "", pull_only: bool = False, 367 system: str = SYSTEM_PROMPT, history: list[dict] | None = None, 368 num_ctx: int | None = None, temperature: float = 0.2, 369 num_predict: int = MAX_ANSWER_TOKENS, think: bool = False): 370 """``system``: the model's instructions (a review's, by default; 371 EXPLAIN_SYSTEM_PROMPT to explain one problem, CHAT_SYSTEM_PROMPT to 372 chat, EDIT_SYSTEM_PROMPT for edits); ``history``: the conversation so 373 far, as chat messages (``{"role": "user" | "assistant", "content": 374 ...}``); ``num_ctx``: the context window (by default, from the 375 prompt's and answer's lengths); ``num_predict``: the answer's length 376 (0: no limit); ``think``: let a thinking model reason first (it gets 377 REASONING_TOKENS more room for that).""" 378 super().__init__() 379 self.host, self.model, self.prompt, self.pull_only = host, model, prompt, pull_only 380 self.system = system 381 self.history = list(history or []) 382 self.num_ctx, self.temperature, self.num_predict, self.think = num_ctx, temperature, num_predict, think 383 self._cancel = threading.Event() 384 self._client = None 385 386 @property 387 def limit(self) -> int: 388 """The tokens the model may write (reasoning included); 0: no limit.""" 389 if self.num_predict <= 0: 390 return 0 391 return self.num_predict + (REASONING_TOKENS if self.think else 0) 392 393 def cancel(self): 394 """Stop (from the GUI thread). Closing the connection also stops a 395 request that is still waiting for its first answer: the model can 396 take a long time to load and read the prompt, and only then would 397 the stopped flag be looked at. Ollama stops generating when the 398 connection goes away.""" 399 self._cancel.set() 400 client = self._client 401 if client is not None: 402 with contextlib.suppress(Exception): # (already closed: fine) 403 client.close() # (its connection) 404 405 def _stopped_message(self) -> str: 406 return "Download cancelled" if self.pull_only else "Stopped" 407 408 @Slot() 409 def run(self): 410 try: 411 self._client = make_client(self.host) 412 if self.pull_only: 413 self._pull(self._client) 414 else: 415 self._review(self._client) 416 except Exception as exc: # noqa: BLE001 - shown to the user 417 if self._cancel.is_set(): # the connection was closed to stop it 418 self.failed.emit(self._stopped_message()) 419 else: 420 self.failed.emit(str(exc) or type(exc).__name__) 421 422 def _pull(self, client): 423 for part in client.pull(self.model, stream=True): 424 if self._cancel.is_set(): 425 self.failed.emit(self._stopped_message()) 426 return 427 status = getattr(part, "status", "") or "" 428 total = getattr(part, "total", None) or 0 429 completed = getattr(part, "completed", None) or 0 430 self.progress.emit(status, completed / total if total else -1.0) 431 self.finished.emit("", {}) 432 433 def _review(self, client): 434 asked = self.prompt + self.system + "".join(m["content"] for m in self.history) 435 options = { 436 "num_ctx": self.num_ctx or context_for(len(asked) // 3, answer=self.limit or REASONING_TOKENS), 437 "temperature": self.temperature, 438 # Discourage the repetition loops small models fall into, and 439 # cap the answer so a runaway one always ends. 440 "repeat_penalty": 1.1, 441 "repeat_last_n": 512, 442 "num_predict": self.limit or -1, # (-1: no limit) 443 } 444 started = time.monotonic() 445 messages = [{"role": "system", "content": self.system}, *self.history, 446 {"role": "user", "content": self.prompt}] 447 # Thinking models (qwen3, deepseek-r1…) reason at length before they 448 # answer, and that counts against num_predict: unless it's asked for 449 # (with room of its own, see limit), thinking is turned off, which is 450 # also much faster. A model that can't think, or can't stop, says so: 451 # it's asked again as it is. 452 stream = client.chat(model=self.model, stream=True, options=options, messages=messages, think=self.think) 453 parts_iter = iter(stream) 454 try: 455 first = next(parts_iter, None) 456 except Exception as exc: 457 if "think" not in str(exc).lower(): 458 raise 459 stream = client.chat(model=self.model, stream=True, options=options, messages=messages) 460 parts_iter = iter(stream) 461 first = next(parts_iter, None) 462 guard = RepeatGuard() 463 parts: list[str] = [] 464 stats: dict = {} 465 thought = 0 # characters of reasoning (a model that thinks anyway) 466 try: 467 for part in _chain(first, parts_iter): 468 if self._cancel.is_set(): 469 self.failed.emit(self._stopped_message()) 470 return 471 message = getattr(part, "message", None) 472 text = getattr(message, "content", "") if message is not None else "" 473 thinking = (getattr(message, "thinking", "") or "") if message is not None else "" 474 if thinking: 475 thought += len(thinking) 476 self.reasoning.emit(thinking) 477 if text: 478 shown, stop = guard.feed(text) 479 if shown: 480 parts.append(shown) 481 self.chunk.emit(shown) 482 if stop: 483 break # closing the stream makes Ollama stop generating 484 if getattr(part, "done", False): 485 stats = {"tokens": getattr(part, "eval_count", 0) or 0, 486 "prompt_tokens": getattr(part, "prompt_eval_count", 0) or 0, 487 "seconds": (getattr(part, "total_duration", 0) or 0) / 1e9} 488 if self.limit and (stats["tokens"] or 0) >= self.limit: 489 stats["truncated"] = True 490 if thought and thought > 3 * sum(map(len, parts)): 491 stats["thinking"] = True # (the limit went on reasoning, not the answer) 492 finally: 493 close = getattr(stream, "close", None) 494 if close is not None: 495 with contextlib.suppress(Exception): # the connection may be gone (stopped) 496 close() 497 rest = guard.flush() 498 if rest: 499 parts.append(rest) 500 self.chunk.emit(rest) 501 if guard.repeated: 502 stats = {"seconds": time.monotonic() - started, "repeated": True} 503 self.chunk.emit("\n[The model started repeating itself, so the review was stopped here.]\n") 504 self.finished.emit("".join(parts), stats)
Runs a review (or a model download) on a background thread.
366 def __init__(self, host: str | None, model: str, prompt: str = "", pull_only: bool = False, 367 system: str = SYSTEM_PROMPT, history: list[dict] | None = None, 368 num_ctx: int | None = None, temperature: float = 0.2, 369 num_predict: int = MAX_ANSWER_TOKENS, think: bool = False): 370 """``system``: the model's instructions (a review's, by default; 371 EXPLAIN_SYSTEM_PROMPT to explain one problem, CHAT_SYSTEM_PROMPT to 372 chat, EDIT_SYSTEM_PROMPT for edits); ``history``: the conversation so 373 far, as chat messages (``{"role": "user" | "assistant", "content": 374 ...}``); ``num_ctx``: the context window (by default, from the 375 prompt's and answer's lengths); ``num_predict``: the answer's length 376 (0: no limit); ``think``: let a thinking model reason first (it gets 377 REASONING_TOKENS more room for that).""" 378 super().__init__() 379 self.host, self.model, self.prompt, self.pull_only = host, model, prompt, pull_only 380 self.system = system 381 self.history = list(history or []) 382 self.num_ctx, self.temperature, self.num_predict, self.think = num_ctx, temperature, num_predict, think 383 self._cancel = threading.Event() 384 self._client = None
system: the model's instructions (a review's, by default;
EXPLAIN_SYSTEM_PROMPT to explain one problem, CHAT_SYSTEM_PROMPT to
chat, EDIT_SYSTEM_PROMPT for edits); history: the conversation so
far, as chat messages ({"role": "user" | "assistant", "content":
...}); num_ctx: the context window (by default, from the
prompt's and answer's lengths); num_predict: the answer's length
(0: no limit); think: let a thinking model reason first (it gets
REASONING_TOKENS more room for that).
386 @property 387 def limit(self) -> int: 388 """The tokens the model may write (reasoning included); 0: no limit.""" 389 if self.num_predict <= 0: 390 return 0 391 return self.num_predict + (REASONING_TOKENS if self.think else 0)
The tokens the model may write (reasoning included); 0: no limit.
393 def cancel(self): 394 """Stop (from the GUI thread). Closing the connection also stops a 395 request that is still waiting for its first answer: the model can 396 take a long time to load and read the prompt, and only then would 397 the stopped flag be looked at. Ollama stops generating when the 398 connection goes away.""" 399 self._cancel.set() 400 client = self._client 401 if client is not None: 402 with contextlib.suppress(Exception): # (already closed: fine) 403 client.close() # (its connection)
Stop (from the GUI thread). Closing the connection also stops a request that is still waiting for its first answer: the model can take a long time to load and read the prompt, and only then would the stopped flag be looked at. Ollama stops generating when the connection goes away.
408 @Slot() 409 def run(self): 410 try: 411 self._client = make_client(self.host) 412 if self.pull_only: 413 self._pull(self._client) 414 else: 415 self._review(self._client) 416 except Exception as exc: # noqa: BLE001 - shown to the user 417 if self._cancel.is_set(): # the connection was closed to stop it 418 self.failed.emit(self._stopped_message()) 419 else: 420 self.failed.emit(str(exc) or type(exc).__name__)
514def start_worker(worker: ReviewWorker, parent: QObject) -> QThread: 515 """Run ``worker`` on a new thread that cleans itself up when done.""" 516 thread = QThread(parent) 517 worker.moveToThread(thread) 518 thread.started.connect(worker.run) 519 worker.finished.connect(thread.quit) 520 worker.failed.connect(thread.quit) 521 thread.finished.connect(worker.deleteLater) 522 thread.finished.connect(thread.deleteLater) 523 thread.start() 524 return thread
Run worker on a new thread that cleans itself up when done.