codegraph-brain 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. cgis/__init__.py +10 -0
  2. cgis/__main__.py +16 -0
  3. cgis/api/.gitkeep +0 -0
  4. cgis/api/__init__.py +1 -0
  5. cgis/api/mcp_server.py +550 -0
  6. cgis/cli.py +1516 -0
  7. cgis/core/.gitkeep +0 -0
  8. cgis/core/models.py +140 -0
  9. cgis/extractors/.gitkeep +0 -0
  10. cgis/extractors/_python_ast.py +194 -0
  11. cgis/extractors/_python_classes.py +126 -0
  12. cgis/extractors/_python_functions.py +312 -0
  13. cgis/extractors/_python_imports.py +188 -0
  14. cgis/extractors/_python_types.py +84 -0
  15. cgis/extractors/base.py +42 -0
  16. cgis/extractors/python_extractor.py +235 -0
  17. cgis/extractors/typescript_extractor.py +310 -0
  18. cgis/guardian/__init__.py +1 -0
  19. cgis/guardian/bench.py +199 -0
  20. cgis/guardian/chunked.py +240 -0
  21. cgis/guardian/chunker.py +148 -0
  22. cgis/guardian/collector.py +309 -0
  23. cgis/guardian/core.py +120 -0
  24. cgis/guardian/diff_index.py +151 -0
  25. cgis/guardian/findings.py +70 -0
  26. cgis/guardian/github_poster.py +133 -0
  27. cgis/guardian/metrics.py +108 -0
  28. cgis/guardian/prompts.py +217 -0
  29. cgis/guardian/providers/__init__.py +1 -0
  30. cgis/guardian/providers/base.py +112 -0
  31. cgis/guardian/providers/gemini.py +83 -0
  32. cgis/guardian/providers/mistral.py +83 -0
  33. cgis/guardian/providers/ollama.py +99 -0
  34. cgis/guardian/recording.py +82 -0
  35. cgis/guardian/render.py +107 -0
  36. cgis/guardian/runner.py +295 -0
  37. cgis/guardian/skeptic.py +208 -0
  38. cgis/pipeline.py +252 -0
  39. cgis/py.typed +0 -0
  40. cgis/query/analysis/__init__.py +0 -0
  41. cgis/query/analysis/analyzer.py +241 -0
  42. cgis/query/analysis/anomaly.py +34 -0
  43. cgis/query/analysis/cohesion.py +277 -0
  44. cgis/query/analysis/health.py +128 -0
  45. cgis/query/analysis/suggest_service.py +221 -0
  46. cgis/query/context/__init__.py +0 -0
  47. cgis/query/context/audit.py +134 -0
  48. cgis/query/context/context_service.py +129 -0
  49. cgis/query/context/prompt.py +184 -0
  50. cgis/query/context/snippet.py +96 -0
  51. cgis/query/drift/__init__.py +0 -0
  52. cgis/query/drift/_scc.py +90 -0
  53. cgis/query/drift/drift.py +867 -0
  54. cgis/query/drift/drift_service.py +217 -0
  55. cgis/query/drift/fingerprint.py +255 -0
  56. cgis/query/drift/fractal.py +292 -0
  57. cgis/query/drift/ontology_init.py +470 -0
  58. cgis/query/drift/quotient.py +75 -0
  59. cgis/query/drift/triads.py +234 -0
  60. cgis/query/engine.py +170 -0
  61. cgis/query/fqn.py +65 -0
  62. cgis/query/render/__init__.py +0 -0
  63. cgis/query/render/graph_json.py +42 -0
  64. cgis/query/render/mermaid.py +235 -0
  65. cgis/query/render/metrics.py +346 -0
  66. cgis/resolver/.gitkeep +0 -0
  67. cgis/resolver/__init__.py +1 -0
  68. cgis/resolver/engine.py +145 -0
  69. cgis/resolver/indices.py +184 -0
  70. cgis/resolver/symbols.py +179 -0
  71. cgis/resolver/uplift.py +263 -0
  72. cgis/storage/.gitkeep +0 -0
  73. cgis/storage/sqlite_store.py +589 -0
  74. codegraph_brain-0.6.0.dist-info/METADATA +240 -0
  75. codegraph_brain-0.6.0.dist-info/RECORD +78 -0
  76. codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
  77. codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
  78. codegraph_brain-0.6.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,83 @@
1
+ """Mistral AI LLM provider for Guardian."""
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from cgis.guardian.providers.base import DEFAULT_REQUEST_TIMEOUT, BaseProvider, ProviderUsage
6
+
7
+
8
+ class MistralProvider(BaseProvider):
9
+ """Mistral AI provider. Requires: uv sync --group guardian"""
10
+
11
+ def __init__(
12
+ self,
13
+ api_key: str,
14
+ model_name: str = "mistral-medium-latest",
15
+ timeout: float = DEFAULT_REQUEST_TIMEOUT,
16
+ ) -> None:
17
+ """Store credentials and the request timeout; mistralai is imported lazily.
18
+
19
+ The SDK takes MILLISECONDS and hard-codes 60 000 when given nothing
20
+ (mistralai/client/chat.py) โ€” that default is what killed the review on
21
+ #274, so it is always overridden here.
22
+ """
23
+ super().__init__()
24
+ self._api_key = api_key
25
+ self._model_name = model_name
26
+ self._timeout_ms = int(timeout * 1000)
27
+
28
+ async def _generate(self, system_prompt: str, user_prompt: str, *, json_mode: bool) -> str:
29
+ """Shared transport: one chat.complete_async call, optional json_object mode."""
30
+ _install_hint = "mistralai is required. Install with: uv sync --group guardian"
31
+ try:
32
+ from mistralai.client import Mistral # noqa: PLC0415
33
+ except ImportError as exc:
34
+ raise ImportError(_install_hint) from exc
35
+ extra: dict[str, object] = {}
36
+ if json_mode:
37
+ extra["response_format"] = {"type": "json_object"}
38
+ async with Mistral(api_key=self._api_key, timeout_ms=self._timeout_ms) as client:
39
+ response = await client.chat.complete_async(
40
+ model=self._model_name,
41
+ messages=[
42
+ {"role": "system", "content": system_prompt},
43
+ {"role": "user", "content": user_prompt},
44
+ ],
45
+ **extra,
46
+ )
47
+ if not response.choices:
48
+ _msg = f"Mistral returned no choices for model {self._model_name}"
49
+ raise ValueError(_msg)
50
+ content = response.choices[0].message.content
51
+ if content is None:
52
+ _msg = f"Mistral returned null message content for model {self._model_name}"
53
+ raise ValueError(_msg)
54
+ usage = getattr(response, "usage", None)
55
+ if usage is not None:
56
+ self._record_usage(
57
+ ProviderUsage(
58
+ prompt_tokens=getattr(usage, "prompt_tokens", 0),
59
+ completion_tokens=getattr(usage, "completion_tokens", 0),
60
+ )
61
+ )
62
+ return str(content)
63
+
64
+ async def generate_content(self, system_prompt: str, user_prompt: str) -> str:
65
+ """Send prompts to Mistral and return the text response."""
66
+ return await self._retry(
67
+ lambda: self._generate(system_prompt, user_prompt, json_mode=False)
68
+ )
69
+
70
+ async def generate_structured(
71
+ self,
72
+ system_prompt: str,
73
+ user_prompt: str,
74
+ schema: type[BaseModel],
75
+ ) -> str:
76
+ """Send prompts in json_object mode.
77
+
78
+ Mistral's json_object mode takes no schema parameter โ€” the schema is
79
+ described in the user prompt (spec ยง2.4); the argument exists to
80
+ satisfy the BaseProvider contract.
81
+ """
82
+ del schema
83
+ return await self._retry(lambda: self._generate(system_prompt, user_prompt, json_mode=True))
@@ -0,0 +1,99 @@
1
+ """Ollama LLM provider for Guardian โ€” local/colab inference, no API key.
2
+
3
+ Targets an Ollama server (default http://localhost:11434, or a tunneled port via
4
+ GUARDIAN_OLLAMA_HOST). The model must already be pulled on that server
5
+ (`ollama pull <model>`). Useful for free local benching and for a cross-model
6
+ skeptic built from two distinct local models.
7
+ """
8
+
9
+ from pydantic import BaseModel
10
+
11
+ from cgis.guardian.providers.base import BaseProvider, ProviderUsage
12
+
13
+ # Colab/local GPUs are slow and cold-load weights on the first call โ€” a tight
14
+ # timeout would spuriously fail the first request of every run.
15
+ DEFAULT_OLLAMA_TIMEOUT = 600.0
16
+
17
+ # Ollama defaults num_ctx to ~2048 and SILENTLY TRUNCATES longer prompts โ€” which
18
+ # would feed the finder a fraction of the diff and quietly tank recall. Guardian
19
+ # prompts (diff + graph + files) run tens of thousands of tokens, so set an
20
+ # explicit window. 32768 matches qwen2.5-coder's max; raise it (e.g. for
21
+ # llama3.1's 128k) or lower it for VRAM via GUARDIAN_OLLAMA_NUM_CTX.
22
+ DEFAULT_OLLAMA_NUM_CTX = 32768
23
+
24
+
25
+ class OllamaProvider(BaseProvider):
26
+ """Ollama provider. Requires: uv sync --group guardian (ollama client)."""
27
+
28
+ def __init__(
29
+ self,
30
+ model_name: str,
31
+ host: str | None = None,
32
+ timeout: float = DEFAULT_OLLAMA_TIMEOUT,
33
+ num_ctx: int = DEFAULT_OLLAMA_NUM_CTX,
34
+ ) -> None:
35
+ """Store model, host (None โ†’ localhost:11434), timeout, and context window."""
36
+ super().__init__()
37
+ self._model_name = model_name
38
+ self._host = host
39
+ self._timeout = timeout
40
+ self._num_ctx = num_ctx
41
+
42
+ async def _generate(
43
+ self, system_prompt: str, user_prompt: str, *, fmt: str | dict[str, object]
44
+ ) -> str:
45
+ """Shared transport: one non-streaming chat call.
46
+
47
+ fmt is "" (free text), "json" (valid JSON, any shape), or a JSON Schema
48
+ dict (constrained decoding โ€” forces a schema-conformant object). Small
49
+ local models emit null in required fields under plain "json"; the schema
50
+ form prevents that.
51
+ """
52
+ _install_hint = "ollama is required. Install with: uv sync --group guardian"
53
+ try:
54
+ from ollama import AsyncClient # noqa: PLC0415
55
+ except ImportError as exc:
56
+ raise ImportError(_install_hint) from exc
57
+ # Context manager so the underlying httpx pool is closed each call.
58
+ async with AsyncClient(host=self._host, timeout=self._timeout) as client:
59
+ response = await client.chat(
60
+ model=self._model_name,
61
+ messages=[
62
+ {"role": "system", "content": system_prompt},
63
+ {"role": "user", "content": user_prompt},
64
+ ],
65
+ stream=False,
66
+ format=fmt,
67
+ options={"num_ctx": self._num_ctx},
68
+ )
69
+ content = response.message.content
70
+ if content is None:
71
+ _msg = f"Ollama returned null message content for model {self._model_name}"
72
+ raise ValueError(_msg)
73
+ self._record_usage(
74
+ ProviderUsage(
75
+ prompt_tokens=response.prompt_eval_count or 0,
76
+ completion_tokens=response.eval_count or 0,
77
+ )
78
+ )
79
+ return str(content)
80
+
81
+ async def generate_content(self, system_prompt: str, user_prompt: str) -> str:
82
+ """Send prompts to Ollama and return the text response."""
83
+ return await self._retry(lambda: self._generate(system_prompt, user_prompt, fmt=""))
84
+
85
+ async def generate_structured(
86
+ self,
87
+ system_prompt: str,
88
+ user_prompt: str,
89
+ schema: type[BaseModel],
90
+ ) -> str:
91
+ """Send prompts with Ollama's schema-constrained format (structured output).
92
+
93
+ Passing the model's JSON Schema makes Ollama constrain decoding to a
94
+ conformant object โ€” small local models otherwise emit null in required
95
+ fields under plain "json" mode, which fails strict validation downstream.
96
+ """
97
+ return await self._retry(
98
+ lambda: self._generate(system_prompt, user_prompt, fmt=schema.model_json_schema())
99
+ )
@@ -0,0 +1,82 @@
1
+ """Persisted finder passes: the recording format shared by bench and production.
2
+
3
+ Extracted from bench.py (#279) once run_guardian became a second consumer โ€”
4
+ production code must not import the benchmark module. Same reason diff_index.py
5
+ was split out of chunker.py when the skeptic pass needed the per-file split.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from cgis.guardian.findings import ReviewResult
13
+
14
+
15
+ class FinderRecording(BaseModel, frozen=True):
16
+ """A frozen finder pass: its findings plus the diff they were found in (#246 ยง4.1).
17
+
18
+ The diff travels with the findings so a replay needs no worktree, ingest or
19
+ git at all โ€” without it, isolating the skeptic would still pay the whole
20
+ setup cost the replay exists to avoid.
21
+ """
22
+
23
+ result: ReviewResult
24
+ diff: str
25
+
26
+
27
+ def _validated_recording_path(path: Path, *, must_exist: bool) -> Path:
28
+ """Resolve and check a CLI-supplied recording path before touching the filesystem.
29
+
30
+ Both entry points take their path from a benchmark CLI flag, so the value is
31
+ untrusted input. Validation mirrors the precedent set for the MCP drift
32
+ surface (#167): resolve, require the expected suffix, and require an
33
+ existing file when reading โ€” deliberately WITHOUT confining the path under
34
+ the CWD, which would break the legitimate cross-repo/tmpdir workflows this
35
+ tool is used in.
36
+ """
37
+ resolved = path.expanduser().resolve()
38
+ if resolved.suffix != ".json":
39
+ _msg = f"Recording path must be a .json file: {path}"
40
+ raise ValueError(_msg)
41
+ if must_exist and not resolved.is_file():
42
+ _msg = f"Recording path is not a file: {path}"
43
+ raise ValueError(_msg)
44
+ return resolved
45
+
46
+
47
+ def save_finder_recording(path: Path, result: ReviewResult, diff: str) -> None:
48
+ """Write a finder pass to disk for later replay.
49
+
50
+ A production recording is taken AFTER the skeptic ran, which is safe because
51
+ load_finder_recording strips verdicts on read. One caveat travels with it:
52
+ apply_judgements rewrites confidence to round(confidence * 0.9) for
53
+ 'uncertain' verdicts and the load path does not restore it, so a recorded
54
+ confidence on that subset is not the finder's own number (#279).
55
+ """
56
+ target = _validated_recording_path(path, must_exist=False)
57
+ recording = FinderRecording(result=result, diff=diff)
58
+ target.write_text(recording.model_dump_json(), encoding="utf-8")
59
+
60
+
61
+ def load_finder_recording(path: Path) -> FinderRecording:
62
+ """Load a recorded finder pass, stripping any skeptic verdicts it carries.
63
+
64
+ A recording captured from a run that HAD a skeptic would otherwise smuggle
65
+ those verdicts into the next variant's scoring โ€” every replay must start
66
+ from unjudged findings, or the arms are not comparable.
67
+ """
68
+ source = _validated_recording_path(path, must_exist=True)
69
+ recording = FinderRecording.model_validate_json(source.read_text(encoding="utf-8"))
70
+ findings = [
71
+ f.model_copy(update={"verdict": None, "skeptic_note": None, "impact_score": None})
72
+ for f in recording.result.findings
73
+ ]
74
+ clean = recording.result.model_copy(
75
+ update={
76
+ "findings": findings,
77
+ "skeptic_status": "off",
78
+ "skeptic_judged": 0,
79
+ "skeptic_total": 0,
80
+ }
81
+ )
82
+ return recording.model_copy(update={"result": clean})
@@ -0,0 +1,107 @@
1
+ """Pure rendering of ReviewResult into the PR-comment markdown (spec ยง2.5)."""
2
+
3
+ from cgis.guardian.findings import Finding, ReviewResult
4
+ from cgis.guardian.skeptic import visible_findings
5
+
6
+ _SEVERITY_MARKER = {"critical": "๐Ÿ”ด", "major": "๐ŸŸ ", "minor": "๐ŸŸก"}
7
+ _SEVERITY_ORDER = {"critical": 0, "major": 1, "minor": 2}
8
+ _CATEGORY_LABEL = {
9
+ "logic": "Logic Bug",
10
+ "contract": "Library Contract",
11
+ "tests": "Test Coverage",
12
+ "types": "Type Safety",
13
+ "ontology": "Ontology",
14
+ }
15
+
16
+
17
+ def _rank_key(finding: Finding) -> tuple[int, int]:
18
+ """Sort key: impact descending, then severity (#246 ยง3.5).
19
+
20
+ An unjudged finding scores -1 rather than 0 so it sorts after every scored
21
+ one โ€” no score is "no ranking signal", not "scored zero".
22
+ """
23
+ impact = finding.impact_score if finding.impact_score is not None else -1
24
+ return (-impact, _SEVERITY_ORDER[finding.severity])
25
+
26
+
27
+ def render_finding(finding: Finding) -> str:
28
+ """Render one finding in the **[Category] โ€” title** block format."""
29
+ location = (
30
+ f"`{finding.file}:{finding.line}`" if finding.line is not None else f"`{finding.file}`"
31
+ )
32
+ impact = f" ยท Impact: {finding.impact_score}/10" if finding.impact_score is not None else ""
33
+ lines = [
34
+ f"**[{_CATEGORY_LABEL[finding.category]}] โ€” {finding.title}**",
35
+ f"{_SEVERITY_MARKER[finding.severity]} {finding.severity} at {location}"
36
+ f" ยท Confidence: {finding.confidence}%{impact}",
37
+ f"Lines: `` {finding.evidence} ``",
38
+ f"Problem: {finding.problem}",
39
+ f"Fix: {finding.fix}",
40
+ ]
41
+ if finding.verdict is not None:
42
+ note = f" โ€” {finding.skeptic_note}" if finding.skeptic_note else ""
43
+ lines.append(f"Skeptic: {finding.verdict}{note}")
44
+ return "\n".join(lines)
45
+
46
+
47
+ def render_inline_comment(finding: Finding, *, skeptic_model: str | None) -> str:
48
+ """One finding as a standalone inline comment body (spec ยง6.3)."""
49
+ lines = [
50
+ f"{_SEVERITY_MARKER[finding.severity]} "
51
+ f"**[{_CATEGORY_LABEL[finding.category]}] โ€” {finding.title}**",
52
+ f"{finding.problem}",
53
+ f"Fix: {finding.fix}",
54
+ ]
55
+ if finding.verdict == "confirmed" and skeptic_model:
56
+ lines.append(f"_Verified by {skeptic_model}_")
57
+ return "\n\n".join(lines)
58
+
59
+
60
+ def render_review_body(result: ReviewResult, *, outside: list[Finding], threshold: int = 0) -> str:
61
+ """The review's top-level body: summary plus any out-of-diff findings (spec ยง6.3)."""
62
+ if not visible_findings(result.findings, threshold):
63
+ return render_report(result, threshold)
64
+ parts: list[str] = []
65
+ if outside:
66
+ ordered = sorted(outside, key=_rank_key)
67
+ blocks = "\n\n".join(render_finding(f) for f in ordered)
68
+ parts.append(f"### Findings outside the diff\n\n{blocks}")
69
+ parts.append(f"**Summary:** {result.summary}")
70
+ return "\n\n".join(parts)
71
+
72
+
73
+ def render_report(result: ReviewResult, threshold: int = 0) -> str:
74
+ """Render the full review; hidden findings are never hidden silently.
75
+
76
+ ``threshold`` suppresses findings the skeptic scored below it (#246 ยง3.5).
77
+ Both kinds of suppression โ€” refuted and below-threshold โ€” are counted in the
78
+ notes, and both stay in ``result.findings`` for metrics.
79
+ """
80
+ if result.parse_failed:
81
+ return (
82
+ "โš ๏ธ Guardian could not produce structured output; raw response below.\n\n"
83
+ + result.summary
84
+ )
85
+ unrefuted = visible_findings(result.findings)
86
+ visible = visible_findings(result.findings, threshold)
87
+ refuted_count = len(result.findings) - len(unrefuted)
88
+ below_threshold = len(unrefuted) - len(visible)
89
+ notes: list[str] = []
90
+ if refuted_count:
91
+ plural = "finding was" if refuted_count == 1 else "findings were"
92
+ notes.append(f"_{refuted_count} {plural} refuted by the skeptic pass._")
93
+ if below_threshold:
94
+ plural = "finding was" if below_threshold == 1 else "findings were"
95
+ notes.append(f"_{below_threshold} {plural} below the impact threshold ({threshold})._")
96
+ if result.skeptic_status == "failed":
97
+ notes.append("_Skeptic pass failed; findings are single-pass._")
98
+ if result.skeptic_status == "partial":
99
+ notes.append(
100
+ f"_Skeptic judged {result.skeptic_judged} of {result.skeptic_total} findings; "
101
+ "the rest are single-pass._"
102
+ )
103
+ suffix = ("\n\n" + "\n".join(notes)) if notes else ""
104
+ if not visible:
105
+ return f"LGTM โ€” no defects found in this diff.\n\n{result.summary}{suffix}"
106
+ blocks = [render_finding(f) for f in sorted(visible, key=_rank_key)]
107
+ return "\n\n".join(blocks) + f"\n\n---\n**Summary:** {result.summary}{suffix}"
@@ -0,0 +1,295 @@
1
+ """Testable orchestration for the guardian review script."""
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+
8
+ import structlog
9
+
10
+ from cgis.guardian.chunked import run_review_routed
11
+ from cgis.guardian.collector import ContextCollector
12
+ from cgis.guardian.diff_index import diff_line_content
13
+ from cgis.guardian.findings import ReviewResult
14
+ from cgis.guardian.github_poster import post_inline_review
15
+ from cgis.guardian.metrics import SkepticRecord, record_review
16
+ from cgis.guardian.providers.base import BaseProvider, ProviderUsage
17
+ from cgis.guardian.providers.gemini import GeminiProvider
18
+ from cgis.guardian.providers.mistral import MistralProvider
19
+ from cgis.guardian.providers.ollama import DEFAULT_OLLAMA_NUM_CTX, OllamaProvider
20
+ from cgis.guardian.recording import save_finder_recording
21
+ from cgis.guardian.render import render_report
22
+
23
+ log = structlog.getLogger(__name__)
24
+
25
+ DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
26
+ DEFAULT_MISTRAL_MODEL = "mistral-medium-latest"
27
+
28
+
29
+ def _ollama_host(env: Mapping[str, str]) -> str | None:
30
+ """GUARDIAN_OLLAMA_HOST, or None so the ollama client uses localhost:11434.
31
+
32
+ Collapses empty/whitespace-only values to None at the boundary so a blank
33
+ env var never reaches the client as an invalid host.
34
+ """
35
+ return (env.get("GUARDIAN_OLLAMA_HOST") or "").strip() or None
36
+
37
+
38
+ def impact_threshold(env: Mapping[str, str]) -> int:
39
+ """GUARDIAN_IMPACT_THRESHOLD clamped to 0-10; 0 (nothing hidden) on absence or garbage.
40
+
41
+ Ships inert on purpose (#246 ยง3.5): the threshold is chosen from the
42
+ benchmark's score distribution, not guessed. A garbage value must not
43
+ silently hide findings, so it degrades to "hide nothing".
44
+ """
45
+ raw = (env.get("GUARDIAN_IMPACT_THRESHOLD") or "").strip()
46
+ if not raw:
47
+ return 0
48
+ try:
49
+ return max(0, min(10, int(raw)))
50
+ except ValueError:
51
+ log.warning("Invalid GUARDIAN_IMPACT_THRESHOLD; hiding nothing.", value=raw)
52
+ return 0
53
+
54
+
55
+ def _ollama_num_ctx(env: Mapping[str, str]) -> int:
56
+ """GUARDIAN_OLLAMA_NUM_CTX as an int, or the provider default (avoids silent truncation)."""
57
+ raw = (env.get("GUARDIAN_OLLAMA_NUM_CTX") or "").strip()
58
+ if not raw:
59
+ return DEFAULT_OLLAMA_NUM_CTX
60
+ try:
61
+ return int(raw)
62
+ except ValueError:
63
+ log.warning("Invalid GUARDIAN_OLLAMA_NUM_CTX; using default.", value=raw)
64
+ return DEFAULT_OLLAMA_NUM_CTX
65
+
66
+
67
+ def _build_mistral(env: Mapping[str, str], model_override: str | None) -> tuple[BaseProvider, str]:
68
+ """Construct a MistralProvider; MISTRAL_API_KEY is required."""
69
+ key = env.get("MISTRAL_API_KEY")
70
+ if not key:
71
+ _msg = "MISTRAL_API_KEY must be set when GUARDIAN_PROVIDER=mistral"
72
+ raise RuntimeError(_msg)
73
+ model = model_override or DEFAULT_MISTRAL_MODEL
74
+ return MistralProvider(api_key=key, model_name=model), model
75
+
76
+
77
+ def _build_gemini(env: Mapping[str, str], model_override: str | None) -> tuple[BaseProvider, str]:
78
+ """Construct a GeminiProvider; GEMINI_API_KEY is required."""
79
+ key = env.get("GEMINI_API_KEY")
80
+ if not key:
81
+ _msg = "GEMINI_API_KEY must be set when GUARDIAN_PROVIDER=gemini"
82
+ raise RuntimeError(_msg)
83
+ model = model_override or DEFAULT_GEMINI_MODEL
84
+ return GeminiProvider(api_key=key, model_name=model), model
85
+
86
+
87
+ def _build_ollama(env: Mapping[str, str], model_override: str | None) -> tuple[BaseProvider, str]:
88
+ """Construct an OllamaProvider; GUARDIAN_MODEL names the model (no API key)."""
89
+ if not model_override:
90
+ _msg = "GUARDIAN_MODEL must name an Ollama model when GUARDIAN_PROVIDER=ollama"
91
+ raise RuntimeError(_msg)
92
+ provider = OllamaProvider(
93
+ model_name=model_override, host=_ollama_host(env), num_ctx=_ollama_num_ctx(env)
94
+ )
95
+ return provider, model_override
96
+
97
+
98
+ def build_provider(env: Mapping[str, str]) -> tuple[BaseProvider, str]:
99
+ """Return (provider, model_name) from GUARDIAN_PROVIDER / available API keys.
100
+
101
+ With no explicit GUARDIAN_PROVIDER, auto-select: mistral if its key is set,
102
+ else gemini if its key is set, else an error.
103
+ """
104
+ model_override = env.get("GUARDIAN_MODEL")
105
+ provider_name = env.get("GUARDIAN_PROVIDER", "").lower() or _autodetect_provider(env)
106
+
107
+ builders = {"mistral": _build_mistral, "gemini": _build_gemini, "ollama": _build_ollama}
108
+ builder = builders.get(provider_name)
109
+ if builder is None:
110
+ _msg = f"Unknown GUARDIAN_PROVIDER={provider_name!r}. Use 'mistral', 'gemini', or 'ollama'."
111
+ raise RuntimeError(_msg)
112
+ return builder(env, model_override)
113
+
114
+
115
+ def _autodetect_provider(env: Mapping[str, str]) -> str:
116
+ """Pick a provider from available API keys when GUARDIAN_PROVIDER is unset."""
117
+ if env.get("MISTRAL_API_KEY"):
118
+ return "mistral"
119
+ if env.get("GEMINI_API_KEY"):
120
+ return "gemini"
121
+ _msg = "Set MISTRAL_API_KEY or GEMINI_API_KEY to run Guardian."
122
+ raise RuntimeError(_msg)
123
+
124
+
125
+ def build_skeptic_provider(
126
+ env: Mapping[str, str], *, primary: str
127
+ ) -> tuple[BaseProvider, str] | None:
128
+ """Return (skeptic_provider, model) or None for single-pass (spec ยง5.5).
129
+
130
+ Default skeptic = the provider opposite to the primary; GUARDIAN_SKEPTIC
131
+ overrides ('gemini'|'mistral'|'ollama'|'off'); GUARDIAN_SKEPTIC_MODEL overrides
132
+ the model, enabling same-provider/different-model pairs (incl. two distinct
133
+ local Ollama models โ€” a cross-model skeptic with no API cost). A missing API
134
+ key / model degrades to None โ€” a review never fails because of the skeptic.
135
+ """
136
+ choice = env.get("GUARDIAN_SKEPTIC", "").lower()
137
+ if choice == "off":
138
+ return None
139
+ if choice not in ("", "gemini", "mistral", "ollama"):
140
+ log.warning("Unknown GUARDIAN_SKEPTIC; skeptic disabled.", value=choice)
141
+ return None
142
+ name = choice or ("mistral" if primary == "gemini" else "gemini")
143
+ model_override = env.get("GUARDIAN_SKEPTIC_MODEL")
144
+ if name == "ollama":
145
+ model = model_override or env.get("GUARDIAN_MODEL")
146
+ if not model:
147
+ log.warning(
148
+ "Skeptic disabled: set GUARDIAN_SKEPTIC_MODEL (or GUARDIAN_MODEL) "
149
+ "for an ollama skeptic."
150
+ )
151
+ return None
152
+ provider = OllamaProvider(
153
+ model_name=model, host=_ollama_host(env), num_ctx=_ollama_num_ctx(env)
154
+ )
155
+ return provider, model
156
+ if name == "mistral":
157
+ key = env.get("MISTRAL_API_KEY")
158
+ if not key:
159
+ log.warning("Skeptic disabled: MISTRAL_API_KEY not set.")
160
+ return None
161
+ model = model_override or DEFAULT_MISTRAL_MODEL
162
+ return MistralProvider(api_key=key, model_name=model), model
163
+ key = env.get("GEMINI_API_KEY")
164
+ if not key:
165
+ log.warning("Skeptic disabled: GEMINI_API_KEY not set.")
166
+ return None
167
+ model = model_override or DEFAULT_GEMINI_MODEL
168
+ return GeminiProvider(api_key=key, model_name=model), model
169
+
170
+
171
+ def build_footer(
172
+ *,
173
+ model: str,
174
+ usage: ProviderUsage,
175
+ stats: dict[str, int],
176
+ result: ReviewResult | None = None,
177
+ ) -> str:
178
+ """Build the markdown footer with model, token usage, graph coverage and skeptic reach."""
179
+ parts = [f"๐Ÿค– **{model}**"]
180
+ if usage.total_tokens > 0:
181
+ parts.append(
182
+ f"{usage.prompt_tokens:,} prompt + {usage.completion_tokens:,} completion"
183
+ f" = **{usage.total_tokens:,} tokens**"
184
+ )
185
+ if stats.get("total", 0) > 0:
186
+ pct = round(stats.get("with_graph", 0) / stats["total"] * 100)
187
+ parts.append(f"graph {stats.get('with_graph', 0)}/{stats['total']} files ({pct}%)")
188
+ if result is not None and result.skeptic_total:
189
+ parts.append(f"skeptic {result.skeptic_judged}/{result.skeptic_total}")
190
+ return "\n\n---\n> " + " ยท ".join(parts)
191
+
192
+
193
+ async def run_guardian(
194
+ *,
195
+ provider: BaseProvider,
196
+ model: str,
197
+ collector: ContextCollector,
198
+ pr: int | None,
199
+ metrics_path: Path,
200
+ skeptic: tuple[BaseProvider, str] | None = None,
201
+ inline_repo: str | None = None,
202
+ threshold: int = 0,
203
+ record_finder: Path | None = None,
204
+ ) -> tuple[str, bool]:
205
+ """Run the review; try the inline path when configured.
206
+
207
+ Returns (rendered report + footer, posted_inline). posted_inline=False
208
+ covers both "not configured" and "API rejected" โ€” the caller posts the
209
+ big comment in either case (spec ยง6.5).
210
+
211
+ ``record_finder`` writes the finder pass (findings + diff) to that path so
212
+ the review can be re-scored offline against a benchmark entry (#279).
213
+ """
214
+ started = time.monotonic()
215
+ routed = await run_review_routed(
216
+ provider=provider,
217
+ collector=collector,
218
+ skeptic_provider=skeptic[0] if skeptic else None,
219
+ )
220
+ # monotonic, not time(): a wall-clock adjustment mid-review would otherwise
221
+ # produce a negative or nonsense duration.
222
+ duration_s = round(time.monotonic() - started, 2)
223
+ result = routed.result
224
+ if record_finder is not None:
225
+ # Recorded AFTER the skeptic on purpose: load_finder_recording strips
226
+ # verdicts on read, and refuted findings stay in result.findings โ€” so a
227
+ # replay still starts clean, with no second API call to pay for (#279).
228
+ try:
229
+ save_finder_recording(record_finder, result, collector.get_git_diff())
230
+ log.info("Finder pass recorded.", path=str(record_finder))
231
+ except OSError:
232
+ # OSError only, and the narrowness is the point: an environment
233
+ # failure (disk, permissions) must not cost a review that is already
234
+ # done, but a bad --record-finder path raises ValueError from the
235
+ # validator and MUST stay loud. Swallowing that would leave a
236
+ # misconfigured pipeline silently producing no recordings โ€” the very
237
+ # failure this feature exists to prevent (#279).
238
+ log.warning(
239
+ "Finder recording failed; continuing without it.",
240
+ path=str(record_finder),
241
+ exc_info=True,
242
+ )
243
+ report = render_report(result, threshold)
244
+ # Built BEFORE the inline attempt: a successful inline post skips the
245
+ # fallback comment, so the footer must ride inside the review body too
246
+ # (live finding on PR #157 โ€” inline reviews arrived footerless).
247
+ footer = build_footer(
248
+ model=model,
249
+ usage=provider.cumulative_usage,
250
+ stats=collector.graph_stats,
251
+ result=result,
252
+ )
253
+
254
+ posted = False
255
+ if inline_repo is not None and pr is not None:
256
+ try:
257
+ diff_text = collector.get_git_diff()
258
+ content = diff_line_content(diff_text)
259
+ await asyncio.to_thread( # subprocess `gh api` call โ€” keep the loop responsive
260
+ post_inline_review,
261
+ repo=inline_repo,
262
+ pr=pr,
263
+ result=result,
264
+ diff_index={path: set(lines) for path, lines in content.items()},
265
+ skeptic_model=skeptic[1] if skeptic else None,
266
+ footer=footer,
267
+ diff_content=content,
268
+ threshold=threshold,
269
+ )
270
+ posted = True
271
+ except Exception:
272
+ log.warning("Inline review failed; falling back to comment.", exc_info=True)
273
+
274
+ record_review(
275
+ model=model,
276
+ pr=pr,
277
+ prompt_tokens=provider.cumulative_usage.prompt_tokens,
278
+ completion_tokens=provider.cumulative_usage.completion_tokens,
279
+ findings_total=len(result.findings),
280
+ # lgtm counts pre-skeptic findings on purpose: all-refuted is "finder
281
+ # flagged something, skeptic killed it" โ€” not a clean LGTM.
282
+ lgtm=not result.findings and not result.parse_failed,
283
+ parse_failed=result.parse_failed,
284
+ skeptic=SkepticRecord(
285
+ model=skeptic[1] if skeptic else None,
286
+ status=result.skeptic_status,
287
+ judged=result.skeptic_judged,
288
+ total=result.skeptic_total,
289
+ impact_threshold=threshold,
290
+ ),
291
+ chunk_count=routed.chunk_count,
292
+ duration_s=duration_s,
293
+ metrics_path=metrics_path,
294
+ )
295
+ return report + footer, posted