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.
- cgis/__init__.py +10 -0
- cgis/__main__.py +16 -0
- cgis/api/.gitkeep +0 -0
- cgis/api/__init__.py +1 -0
- cgis/api/mcp_server.py +550 -0
- cgis/cli.py +1516 -0
- cgis/core/.gitkeep +0 -0
- cgis/core/models.py +140 -0
- cgis/extractors/.gitkeep +0 -0
- cgis/extractors/_python_ast.py +194 -0
- cgis/extractors/_python_classes.py +126 -0
- cgis/extractors/_python_functions.py +312 -0
- cgis/extractors/_python_imports.py +188 -0
- cgis/extractors/_python_types.py +84 -0
- cgis/extractors/base.py +42 -0
- cgis/extractors/python_extractor.py +235 -0
- cgis/extractors/typescript_extractor.py +310 -0
- cgis/guardian/__init__.py +1 -0
- cgis/guardian/bench.py +199 -0
- cgis/guardian/chunked.py +240 -0
- cgis/guardian/chunker.py +148 -0
- cgis/guardian/collector.py +309 -0
- cgis/guardian/core.py +120 -0
- cgis/guardian/diff_index.py +151 -0
- cgis/guardian/findings.py +70 -0
- cgis/guardian/github_poster.py +133 -0
- cgis/guardian/metrics.py +108 -0
- cgis/guardian/prompts.py +217 -0
- cgis/guardian/providers/__init__.py +1 -0
- cgis/guardian/providers/base.py +112 -0
- cgis/guardian/providers/gemini.py +83 -0
- cgis/guardian/providers/mistral.py +83 -0
- cgis/guardian/providers/ollama.py +99 -0
- cgis/guardian/recording.py +82 -0
- cgis/guardian/render.py +107 -0
- cgis/guardian/runner.py +295 -0
- cgis/guardian/skeptic.py +208 -0
- cgis/pipeline.py +252 -0
- cgis/py.typed +0 -0
- cgis/query/analysis/__init__.py +0 -0
- cgis/query/analysis/analyzer.py +241 -0
- cgis/query/analysis/anomaly.py +34 -0
- cgis/query/analysis/cohesion.py +277 -0
- cgis/query/analysis/health.py +128 -0
- cgis/query/analysis/suggest_service.py +221 -0
- cgis/query/context/__init__.py +0 -0
- cgis/query/context/audit.py +134 -0
- cgis/query/context/context_service.py +129 -0
- cgis/query/context/prompt.py +184 -0
- cgis/query/context/snippet.py +96 -0
- cgis/query/drift/__init__.py +0 -0
- cgis/query/drift/_scc.py +90 -0
- cgis/query/drift/drift.py +867 -0
- cgis/query/drift/drift_service.py +217 -0
- cgis/query/drift/fingerprint.py +255 -0
- cgis/query/drift/fractal.py +292 -0
- cgis/query/drift/ontology_init.py +470 -0
- cgis/query/drift/quotient.py +75 -0
- cgis/query/drift/triads.py +234 -0
- cgis/query/engine.py +170 -0
- cgis/query/fqn.py +65 -0
- cgis/query/render/__init__.py +0 -0
- cgis/query/render/graph_json.py +42 -0
- cgis/query/render/mermaid.py +235 -0
- cgis/query/render/metrics.py +346 -0
- cgis/resolver/.gitkeep +0 -0
- cgis/resolver/__init__.py +1 -0
- cgis/resolver/engine.py +145 -0
- cgis/resolver/indices.py +184 -0
- cgis/resolver/symbols.py +179 -0
- cgis/resolver/uplift.py +263 -0
- cgis/storage/.gitkeep +0 -0
- cgis/storage/sqlite_store.py +589 -0
- codegraph_brain-0.6.0.dist-info/METADATA +240 -0
- codegraph_brain-0.6.0.dist-info/RECORD +78 -0
- codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
- codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
- 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})
|
cgis/guardian/render.py
ADDED
|
@@ -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}"
|
cgis/guardian/runner.py
ADDED
|
@@ -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
|