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
DEFAULT_MODEL = 'qwen2.5-coder:7b'
MAX_ANSWER_TOKENS = 1500
ANSWER_LENGTHS = (1500, 4000, 8000, 16000, 0)
REASONING_TOKENS = 8192
DEFAULT_HOST = None
CATEGORIES = ('bug', 'warning', 'performance', 'style', 'suggestion')
FOCUS = {'general': 'Give a balanced review: correctness first, then clarity, performance and style.', 'bugs': 'Concentrate on real bugs: wrong results, out-of-bounds access, uninitialised variables, integer overflow, wrong intent, aliasing, precision loss, race conditions.', 'modern': 'Concentrate on modernising the code: implicit none, intent, modules instead of common blocks/include, assumed-shape arrays, allocatable instead of pointers, do concurrent where safe, removing obsolescent features (goto, arithmetic if, fixed form, equivalence).', 'performance': 'Concentrate on performance: memory access order (Fortran is column-major), unnecessary temporaries and copies, repeated allocation in loops, vectorisation and OpenMP opportunities.', 'docs': 'Concentrate on documentation and readability: missing or misleading comments, unclear names, FORD-style (!>) doc comments for public procedures.'}
FOCUS_LABELS = {'general': 'General', 'bugs': 'Bugs', 'modern': 'Modern Fortran style', 'performance': 'Performance', 'docs': 'Documentation'}
SYSTEM_PROMPT = 'You are an experienced reviewer of scientific and numerical code, expert in modern Fortran (2008/2018), and also C and Python. You review one file at a time.\n\nRules for your answer:\n- Write one line per finding, in exactly this form:\n LINE <n>: <category>: <comment>\n or, for a range of lines,\n LINE <start>-<end>: <category>: <comment>\n- <category> is one of: bug, warning, performance, style, suggestion.\n- Use the line numbers shown at the start of each source line.\n- Keep each comment to one or two sentences and say how to fix it.\n- Only report real, specific issues. Do not invent problems; if the code is fine, say so.\n- After the findings, write a line starting with "SUMMARY:" with a one to three\n sentence overall assessment.\n- Do not repeat the code and do not use Markdown headings or code blocks.'
@dataclass
class Finding:
77@dataclass
78class Finding:
79    line: int
80    end_line: int
81    category: str
82    comment: str
Finding(line: int, end_line: int, category: str, comment: str)
line: int
end_line: int
category: str
comment: str
FINDING_RE = re.compile('^\\s*(?:[-*•]\\s*|\\d+[.)]\\s*)? # optional bullet / numbering\n \\**\\s*L(?:INE)?S?\\s*\\#?\\s*(\\d+) # LINE 12 / Line 12 / L12\n (?:\\s*(?:-|–|to)\\s*(\\d+))? # opt, re.IGNORECASE|re.VERBOSE)
SUMMARY_RE = re.compile('^\\s*\\**\\s*SUMMARY\\s*\\**\\s*:\\s*(.*)$', re.IGNORECASE)
def parse_findings( text: str, max_line: int | None = None) -> tuple[list[Finding], str]:
 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.

class RepeatGuard:
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.

MIN_LENGTH = 12
repeated
def feed(self, text: str) -> tuple[str, bool]:
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
def flush(self) -> str:
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).

def build_prompt( path: str, language: str, code: str, focus: str, diagnostics: list[tuple[int, str, str]], first_line: int = 1, total_lines: int = 0) -> str:
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.

EXPLAIN_SYSTEM_PROMPT = "You are an expert in modern Fortran, its compilers (gfortran, LFortran, Intel) and linters, helping a programmer understand one message about their code. Answer in Markdown, briefly and concretely, for this code (not in general):\n1. **What it means**: the message in plain words.\n2. **Why here**: what in the marked line (and around it) causes it.\n3. **How to fix it**: the change to make, with the corrected lines in a ```fortran block (code always in fenced blocks labeled with its language, so it can be highlighted).\nIf the message is harmless or a style preference, say so. Don't invent code that isn't shown."
EXPLAIN_CONTEXT_LINES = 15
def build_explain_prompt( path: str, code: str, line: int, kind: str, message: str, rule: str = '', compiler: str = '') -> str:
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 >>).

CHAT_SYSTEM_PROMPT = "You are a helpful assistant for a programmer working on a Fortran project built with fpm (the Fortran Package Manager). You are an expert in modern Fortran (2008/2018), its compilers (gfortran, LFortran, Intel), fpm, and numerical methods. Answer in Markdown, concisely. Put code in fenced blocks labeled with its language (```fortran, ```toml for fpm.toml, ```bash for commands), so it can be highlighted. If you are not sure, say so.\nWhen the user's file is attached (its lines are numbered on the left) and you point out a problem or a change at specific lines, also write each one on a line of its own, exactly as\n LINE <n>: <category>: <comment>\n(or LINE <start>-<end>: ...), where <category> is one of: bug, warning, performance, style, suggestion. Those lines are listed for the user and marked in the editor."
EDIT_SYSTEM_PROMPT = 'You change a programmer\'s Fortran file as they ask. Reply with:\n1. One or two sentences on what you\'ll change.\n2. One SEARCH/REPLACE block per change, in exactly this form:\n<<<<<<< SEARCH\n(lines copied exactly from the file)\n=======\n(the new lines)\n>>>>>>> REPLACE\nRules: change only what was asked; keep everything else exactly as it is (names, comments, formatting, free or fixed form). Copy the SEARCH lines exactly, enough of them (usually 2 to 6) to be found in only one place in the file. Never write "..." or "rest unchanged" inside a block. To delete lines, leave REPLACE empty. If the request is unclear or unsafe, ask instead of editing. Earlier edits in the conversation are marked applied, not applied yet, or rejected: answer the latest request, and don\'t propose a rejected edit again.'
EDIT_ANSWER_TOKENS = 4096
MAX_CONTEXT = 32768
def context_for(tokens: int, cap: int = 32768, answer: int = 1500) -> int:
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.

def model_context_length(model: str, host: str | None = None) -> int | None:
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).

def ollama_available() -> bool:
317def ollama_available() -> bool:
318    try:
319        import ollama  # noqa: F401
320    except ImportError:
321        return False
322    return True
def ollama_executable() -> str | None:
325def ollama_executable() -> str | None:
326    return shutil.which("ollama")
def make_client(host: str | None = None, timeout: float | None = None):
329def make_client(host: str | None = DEFAULT_HOST, timeout: float | None = None):
330    import ollama
331    return ollama.Client(host=host, timeout=timeout)
def server_running(host: str | None = None) -> bool:
334def server_running(host: str | None = DEFAULT_HOST) -> bool:
335    try:
336        make_client(host, timeout=2).list()
337        return True
338    except Exception:  # noqa: BLE001 - any failure means "not reachable"
339        return False
def installed_models(host: str | None = None) -> list[str]:
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)
class ReviewWorker(PySide6.QtCore.QObject):
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.

ReviewWorker( host: str | None, model: str, prompt: str = '', pull_only: bool = False, system: str = 'You are an experienced reviewer of scientific and numerical code, expert in modern Fortran (2008/2018), and also C and Python. You review one file at a time.\n\nRules for your answer:\n- Write one line per finding, in exactly this form:\n LINE <n>: <category>: <comment>\n or, for a range of lines,\n LINE <start>-<end>: <category>: <comment>\n- <category> is one of: bug, warning, performance, style, suggestion.\n- Use the line numbers shown at the start of each source line.\n- Keep each comment to one or two sentences and say how to fix it.\n- Only report real, specific issues. Do not invent problems; if the code is fine, say so.\n- After the findings, write a line starting with "SUMMARY:" with a one to three\n sentence overall assessment.\n- Do not repeat the code and do not use Markdown headings or code blocks.', history: list[dict] | None = None, num_ctx: int | None = None, temperature: float = 0.2, num_predict: int = 1500, think: bool = False)
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).

def chunk(unknown):

The type of the None singleton.

def reasoning(unknown):

The type of the None singleton.

def progress(unknown):

The type of the None singleton.

def finished(unknown):

The type of the None singleton.

def failed(unknown):

The type of the None singleton.

system
history
limit: int
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.

def cancel(self):
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.

@Slot()
def run(self):
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__)
staticMetaObject = PySide6.QtCore.QMetaObject("ReviewWorker" inherits "QObject": Methods: #4 type=Signal, signature=chunk(QString), parameters=QString #5 type=Signal, signature=reasoning(QString), parameters=QString #6 type=Signal, signature=progress(QString,double), parameters=QString, double #7 type=Signal, signature=finished(QString,QVariantMap), parameters=QString, QVariantMap #8 type=Signal, signature=failed(QString), parameters=QString #9 type=Slot, signature=run() )
def start_worker( worker: ReviewWorker, parent: PySide6.QtCore.QObject) -> PySide6.QtCore.QThread:
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.