code-mower 0.5.0b5__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.
- code_mower/__init__.py +3 -0
- code_mower/adapters/__init__.py +39 -0
- code_mower/adapters/_base.py +148 -0
- code_mower/adapters/cursor_bugbot.py +97 -0
- code_mower/adapters/gitar.py +111 -0
- code_mower/adapters/greptile.py +191 -0
- code_mower/adapters/qodo.py +140 -0
- code_mower/antigravity_cli_audit_pr.py +241 -0
- code_mower/audit_handoff_log.py +345 -0
- code_mower/audit_labeler_lib.py +311 -0
- code_mower/audit_progress.py +203 -0
- code_mower/blind_review_artifacts.py +562 -0
- code_mower/blind_review_coordinator.py +276 -0
- code_mower/bootstrap.py +524 -0
- code_mower/builder_experiment.py +539 -0
- code_mower/calibration/__init__.py +181 -0
- code_mower/calibration/arms.py +267 -0
- code_mower/calibration/auto_discovery.py +348 -0
- code_mower/calibration/commands.py +192 -0
- code_mower/calibration/context_inputs.py +199 -0
- code_mower/calibration/corpus.py +91 -0
- code_mower/calibration/evidence.py +20 -0
- code_mower/calibration/evidence_report.py +360 -0
- code_mower/calibration/identity.py +27 -0
- code_mower/calibration/metrics.py +18 -0
- code_mower/calibration/overlap.py +103 -0
- code_mower/calibration/planning.py +309 -0
- code_mower/calibration/policy.py +207 -0
- code_mower/calibration/results.py +315 -0
- code_mower/calibration/run_results.py +147 -0
- code_mower/calibration/run_status.py +64 -0
- code_mower/calibration/runner.py +360 -0
- code_mower/calibration/truth.py +188 -0
- code_mower/calibration/value_report.py +142 -0
- code_mower/checks.py +402 -0
- code_mower/claude_audit_pr.py +1126 -0
- code_mower/claude_cli_bounce.py +303 -0
- code_mower/claude_cli_environment.py +73 -0
- code_mower/clear_stale.py +374 -0
- code_mower/cli.py +537 -0
- code_mower/cloud.py +674 -0
- code_mower/cloud_client/__init__.py +173 -0
- code_mower/cloud_client/bundle.py +155 -0
- code_mower/cloud_client/doctor.py +206 -0
- code_mower/cloud_client/dogfood.py +78 -0
- code_mower/cloud_client/endpoints.py +114 -0
- code_mower/cloud_client/errors.py +7 -0
- code_mower/cloud_client/events.py +278 -0
- code_mower/cloud_client/export.py +272 -0
- code_mower/cloud_client/git_metadata.py +46 -0
- code_mower/cloud_client/manifest.py +39 -0
- code_mower/cloud_client/operations.py +448 -0
- code_mower/cloud_client/reports.py +45 -0
- code_mower/cloud_client/setup.py +205 -0
- code_mower/cloud_client/upload.py +97 -0
- code_mower/code_mower_calibration.py +598 -0
- code_mower/code_mower_context_packs.py +591 -0
- code_mower/code_mower_merge.py +227 -0
- code_mower/code_mower_telemetry.py +561 -0
- code_mower/coderabbit_cli_audit_pr.py +526 -0
- code_mower/codex_audit_env_preflight.py +220 -0
- code_mower/codex_audit_pr.py +1738 -0
- code_mower/codex_audit_schema_smoke.py +160 -0
- code_mower/codex_audit_verdict.schema.json +44 -0
- code_mower/config.py +655 -0
- code_mower/doctor.py +161 -0
- code_mower/doctor_checks/__init__.py +104 -0
- code_mower/doctor_checks/cloud.py +129 -0
- code_mower/doctor_checks/common.py +215 -0
- code_mower/doctor_checks/github.py +128 -0
- code_mower/doctor_checks/github_actions.py +11 -0
- code_mower/doctor_checks/github_actions_cost.py +99 -0
- code_mower/doctor_checks/github_actions_cost_summary.py +111 -0
- code_mower/doctor_checks/github_actions_failure_annotations.py +27 -0
- code_mower/doctor_checks/github_actions_failure_models.py +47 -0
- code_mower/doctor_checks/github_actions_failure_scan.py +200 -0
- code_mower/doctor_checks/github_actions_failure_selection.py +63 -0
- code_mower/doctor_checks/github_actions_failures.py +103 -0
- code_mower/doctor_checks/github_actions_permissions.py +55 -0
- code_mower/doctor_checks/github_api.py +79 -0
- code_mower/doctor_checks/github_branch.py +56 -0
- code_mower/doctor_checks/github_config.py +25 -0
- code_mower/doctor_checks/github_provider.py +61 -0
- code_mower/doctor_checks/github_repo.py +120 -0
- code_mower/doctor_checks/groups.py +36 -0
- code_mower/doctor_checks/models.py +96 -0
- code_mower/doctor_checks/output.py +86 -0
- code_mower/doctor_checks/presets.py +64 -0
- code_mower/doctor_checks/privacy.py +20 -0
- code_mower/doctor_checks/provider_api_model.py +138 -0
- code_mower/doctor_checks/provider_api_model_openai.py +29 -0
- code_mower/doctor_checks/provider_api_model_profiles.py +137 -0
- code_mower/doctor_checks/provider_env.py +113 -0
- code_mower/doctor_checks/provider_env_required.py +56 -0
- code_mower/doctor_checks/provider_env_tokens.py +100 -0
- code_mower/doctor_checks/provider_local_cli.py +162 -0
- code_mower/doctor_checks/provider_local_cli_commands.py +47 -0
- code_mower/doctor_checks/provider_local_cli_probe_config.py +70 -0
- code_mower/doctor_checks/provider_probe.py +20 -0
- code_mower/doctor_checks/provider_probe_auth.py +52 -0
- code_mower/doctor_checks/provider_probe_evaluation.py +109 -0
- code_mower/doctor_checks/provider_probe_json.py +45 -0
- code_mower/doctor_checks/provider_probe_remediation.py +39 -0
- code_mower/doctor_checks/providers.py +159 -0
- code_mower/doctor_checks/registry.py +69 -0
- code_mower/doctor_checks/runner.py +188 -0
- code_mower/doctor_checks/runtime.py +89 -0
- code_mower/doctor_checks/runtime_github_auth.py +148 -0
- code_mower/gemini_cli_audit_pr.py +897 -0
- code_mower/hermes_cli_audit_pr.py +436 -0
- code_mower/init.py +888 -0
- code_mower/lane_configs/__init__.py +37 -0
- code_mower/lane_configs/aider.py +32 -0
- code_mower/lane_configs/antigravity_cli.py +35 -0
- code_mower/lane_configs/claude.py +35 -0
- code_mower/lane_configs/codex.py +32 -0
- code_mower/lane_configs/devin.py +33 -0
- code_mower/lane_configs/gemini_cli.py +35 -0
- code_mower/lane_configs/hermes_cli.py +35 -0
- code_mower/lane_configs/local_llm.py +31 -0
- code_mower/local_llm_audit_pr.py +1364 -0
- code_mower/local_llm_bakeoff.py +458 -0
- code_mower/local_llm_calibration.py +441 -0
- code_mower/local_llm_profiles.py +66 -0
- code_mower/migration.py +508 -0
- code_mower/migration_install.py +292 -0
- code_mower/migration_mirror.py +392 -0
- code_mower/migration_readiness.py +237 -0
- code_mower/migration_rehearsal.py +718 -0
- code_mower/next_steps.py +441 -0
- code_mower/package.py +673 -0
- code_mower/package_content.py +444 -0
- code_mower/package_manifest.py +452 -0
- code_mower/package_paths.py +53 -0
- code_mower/package_rendering.py +90 -0
- code_mower/package_static.py +585 -0
- code_mower/prompts.py +267 -0
- code_mower/provider_registry.py +469 -0
- code_mower/provider_runners/__init__.py +60 -0
- code_mower/provider_runners/comments.py +31 -0
- code_mower/provider_runners/git.py +46 -0
- code_mower/provider_runners/github_auth.py +61 -0
- code_mower/provider_runners/github_pr.py +120 -0
- code_mower/provider_runners/process.py +58 -0
- code_mower/provider_runners/repo_paths.py +23 -0
- code_mower/provider_runners/text_schema.py +41 -0
- code_mower/provider_runners/verdict_artifacts.py +103 -0
- code_mower/provider_runners/workspace.py +57 -0
- code_mower/release_readiness.py +549 -0
- code_mower/reviewer_metrics.py +389 -0
- code_mower/saas_reviewer_labeler.py +809 -0
- code_mower/secrets.py +89 -0
- code_mower/templates/builder-experiment.example.json +55 -0
- code_mower/templates/calibration-corpus.example.json +129 -0
- code_mower/templates/calibration-corpus.json +129 -0
- code_mower/templates/code-mower.example.yml +423 -0
- code_mower/templates/context-packs.example.json +150 -0
- code_mower/templates/lane_prompts/base-audit.md +22 -0
- code_mower/templates/lane_prompts/calibration-policy.md +21 -0
- code_mower/templates/lane_prompts/context-driven-quality.md +21 -0
- code_mower/templates/lane_prompts/docs-design.md +12 -0
- code_mower/templates/lane_prompts/generic-programming.md +21 -0
- code_mower/templates/lane_prompts/operability.md +22 -0
- code_mower/templates/lane_prompts/package-runtime.md +12 -0
- code_mower/templates/lane_prompts/security-threat-model.md +22 -0
- code_mower/templates/product-support/code_mower +216 -0
- code_mower/templates/product-support/code_mower_standalone_pin.env +7 -0
- code_mower/templates/product-support/code_mower_standalone_shadow.sh +151 -0
- code_mower/templates/product-support/run_claude_audit_pr.sh +32 -0
- code_mower/templates/product-support/run_codex_audit_pr.sh +32 -0
- code_mower/templates/product-support/safe_gh_comment.py +96 -0
- code_mower/templates/providers.yml +454 -0
- code_mower/templates/reviewer-spend.example.json +28 -0
- code_mower/templates/reviewer-value-report.example.md +20 -0
- code_mower/templates/workflows/private-standalone-shadow.yml.j2 +106 -0
- code_mower/templates/workflows/review-clear-stale.yml.j2 +83 -0
- code_mower/trailer_comment_labeler.py +207 -0
- code_mower/versioning.py +32 -0
- code_mower-0.5.0b5.dist-info/METADATA +302 -0
- code_mower-0.5.0b5.dist-info/RECORD +185 -0
- code_mower-0.5.0b5.dist-info/WHEEL +5 -0
- code_mower-0.5.0b5.dist-info/entry_points.txt +2 -0
- code_mower-0.5.0b5.dist-info/licenses/LICENSE +202 -0
- code_mower-0.5.0b5.dist-info/licenses/NOTICE +10 -0
- code_mower-0.5.0b5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render calibration reports from local LLM bakeoff summaries."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Iterable, Mapping
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
KNOWN_DISPOSITIONS = {
|
|
17
|
+
"true_positive",
|
|
18
|
+
"false_positive",
|
|
19
|
+
"useful",
|
|
20
|
+
"noise",
|
|
21
|
+
"unknown",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ProfileStats:
|
|
27
|
+
profile_id: str
|
|
28
|
+
model: str = ""
|
|
29
|
+
runs: int = 0
|
|
30
|
+
files_reviewed: int = 0
|
|
31
|
+
blocker_file_count: int = 0
|
|
32
|
+
concern_file_count: int = 0
|
|
33
|
+
parse_failure_count: int = 0
|
|
34
|
+
json_repair_used_count: int = 0
|
|
35
|
+
parse_attempts_total: int = 0
|
|
36
|
+
duration_seconds_total: float = 0.0
|
|
37
|
+
verdicts: Counter[str] = field(default_factory=Counter)
|
|
38
|
+
dispositions: Counter[str] = field(default_factory=Counter)
|
|
39
|
+
|
|
40
|
+
def record_run(self, run: Mapping[str, Any]) -> None:
|
|
41
|
+
self.runs += 1
|
|
42
|
+
self.model = str(run.get("model") or self.model)
|
|
43
|
+
self.files_reviewed += _int(run.get("files_reviewed"))
|
|
44
|
+
self.blocker_file_count += _int(run.get("blocker_file_count"))
|
|
45
|
+
self.concern_file_count += _int(run.get("concern_file_count"))
|
|
46
|
+
self.parse_failure_count += _int(run.get("parse_failure_count"))
|
|
47
|
+
self.json_repair_used_count += _int(run.get("json_repair_used_count"))
|
|
48
|
+
self.parse_attempts_total += _int(run.get("parse_attempts_total"))
|
|
49
|
+
self.duration_seconds_total += _float(run.get("duration_seconds"))
|
|
50
|
+
verdict = str(run.get("verdict") or "UNKNOWN")
|
|
51
|
+
self.verdicts[verdict] += 1
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, Any]:
|
|
54
|
+
average_duration = (
|
|
55
|
+
self.duration_seconds_total / self.runs
|
|
56
|
+
if self.runs
|
|
57
|
+
else 0.0
|
|
58
|
+
)
|
|
59
|
+
parse_failure_rate = (
|
|
60
|
+
self.parse_failure_count / self.files_reviewed
|
|
61
|
+
if self.files_reviewed
|
|
62
|
+
else 0.0
|
|
63
|
+
)
|
|
64
|
+
return {
|
|
65
|
+
"profile_id": self.profile_id,
|
|
66
|
+
"model": self.model,
|
|
67
|
+
"runs": self.runs,
|
|
68
|
+
"files_reviewed": self.files_reviewed,
|
|
69
|
+
"verdicts": dict(sorted(self.verdicts.items())),
|
|
70
|
+
"blocker_file_count": self.blocker_file_count,
|
|
71
|
+
"concern_file_count": self.concern_file_count,
|
|
72
|
+
"parse_failure_count": self.parse_failure_count,
|
|
73
|
+
"json_repair_used_count": self.json_repair_used_count,
|
|
74
|
+
"parse_attempts_total": self.parse_attempts_total,
|
|
75
|
+
"duration_seconds_total": round(self.duration_seconds_total, 3),
|
|
76
|
+
"duration_seconds_average": round(average_duration, 3),
|
|
77
|
+
"parse_failure_rate": round(parse_failure_rate, 4),
|
|
78
|
+
"dispositions": dict(sorted(self.dispositions.items())),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _int(value: Any) -> int:
|
|
83
|
+
if isinstance(value, bool):
|
|
84
|
+
return int(value)
|
|
85
|
+
if isinstance(value, int):
|
|
86
|
+
return value
|
|
87
|
+
if isinstance(value, float):
|
|
88
|
+
return int(value)
|
|
89
|
+
if isinstance(value, str) and value.strip().isdigit():
|
|
90
|
+
return int(value)
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _float(value: Any) -> float:
|
|
95
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
96
|
+
return float(value)
|
|
97
|
+
if isinstance(value, str):
|
|
98
|
+
try:
|
|
99
|
+
return float(value)
|
|
100
|
+
except ValueError:
|
|
101
|
+
return 0.0
|
|
102
|
+
return 0.0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _load_json(path: Path) -> Mapping[str, Any]:
|
|
106
|
+
try:
|
|
107
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
108
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
109
|
+
raise ValueError(f"unable to read {path}: {exc}") from exc
|
|
110
|
+
if not isinstance(payload, Mapping):
|
|
111
|
+
raise ValueError(f"{path} must contain a JSON object")
|
|
112
|
+
return payload
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_dispositions(path: Path | None) -> dict[str, str]:
|
|
116
|
+
if path is None:
|
|
117
|
+
return {}
|
|
118
|
+
payload = _load_json(path)
|
|
119
|
+
dispositions: dict[str, str] = {}
|
|
120
|
+
for finding_id, value in payload.items():
|
|
121
|
+
if isinstance(value, Mapping):
|
|
122
|
+
disposition = str(value.get("disposition", "unknown"))
|
|
123
|
+
else:
|
|
124
|
+
disposition = str(value)
|
|
125
|
+
disposition = disposition.strip().lower()
|
|
126
|
+
if disposition not in KNOWN_DISPOSITIONS:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"unknown disposition for finding {finding_id}: {disposition!r}"
|
|
129
|
+
)
|
|
130
|
+
dispositions[str(finding_id)] = disposition
|
|
131
|
+
return dispositions
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _finding_id(
|
|
135
|
+
*,
|
|
136
|
+
repo: str,
|
|
137
|
+
pr_number: Any,
|
|
138
|
+
head_sha: str,
|
|
139
|
+
profile_id: str,
|
|
140
|
+
severity: str,
|
|
141
|
+
path: str,
|
|
142
|
+
index: int,
|
|
143
|
+
text: str,
|
|
144
|
+
) -> str:
|
|
145
|
+
raw = "\n".join(
|
|
146
|
+
[
|
|
147
|
+
repo,
|
|
148
|
+
str(pr_number),
|
|
149
|
+
head_sha,
|
|
150
|
+
profile_id,
|
|
151
|
+
severity,
|
|
152
|
+
path,
|
|
153
|
+
str(index),
|
|
154
|
+
text,
|
|
155
|
+
]
|
|
156
|
+
)
|
|
157
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def iter_run_findings(
|
|
161
|
+
payload: Mapping[str, Any],
|
|
162
|
+
run: Mapping[str, Any],
|
|
163
|
+
) -> Iterable[dict[str, Any]]:
|
|
164
|
+
repo = str(payload.get("repo") or run.get("repo") or "")
|
|
165
|
+
pr_number = payload.get("pr_number", run.get("pr_number", ""))
|
|
166
|
+
head_sha = str(payload.get("head_sha") or run.get("head_sha_end") or "")
|
|
167
|
+
profile_id = str(run.get("profile_id") or "")
|
|
168
|
+
|
|
169
|
+
for finding_group in run.get("blocker_findings", []) or []:
|
|
170
|
+
if not isinstance(finding_group, Mapping):
|
|
171
|
+
continue
|
|
172
|
+
path = str(finding_group.get("path") or "")
|
|
173
|
+
for index, text in enumerate(finding_group.get("blockers", []) or []):
|
|
174
|
+
text = str(text)
|
|
175
|
+
yield {
|
|
176
|
+
"id": _finding_id(
|
|
177
|
+
repo=repo,
|
|
178
|
+
pr_number=pr_number,
|
|
179
|
+
head_sha=head_sha,
|
|
180
|
+
profile_id=profile_id,
|
|
181
|
+
severity="BLOCKER",
|
|
182
|
+
path=path,
|
|
183
|
+
index=index,
|
|
184
|
+
text=text,
|
|
185
|
+
),
|
|
186
|
+
"profile_id": profile_id,
|
|
187
|
+
"repo": repo,
|
|
188
|
+
"pr_number": pr_number,
|
|
189
|
+
"head_sha": head_sha,
|
|
190
|
+
"severity": "BLOCKER",
|
|
191
|
+
"path": path,
|
|
192
|
+
"text": text,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for index, text in enumerate(run.get("pr_level_blockers", []) or []):
|
|
196
|
+
text = str(text)
|
|
197
|
+
yield {
|
|
198
|
+
"id": _finding_id(
|
|
199
|
+
repo=repo,
|
|
200
|
+
pr_number=pr_number,
|
|
201
|
+
head_sha=head_sha,
|
|
202
|
+
profile_id=profile_id,
|
|
203
|
+
severity="BLOCKER",
|
|
204
|
+
path="__pr__",
|
|
205
|
+
index=index,
|
|
206
|
+
text=text,
|
|
207
|
+
),
|
|
208
|
+
"profile_id": profile_id,
|
|
209
|
+
"repo": repo,
|
|
210
|
+
"pr_number": pr_number,
|
|
211
|
+
"head_sha": head_sha,
|
|
212
|
+
"severity": "BLOCKER",
|
|
213
|
+
"path": "__pr__",
|
|
214
|
+
"text": text,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for finding_group in run.get("concern_findings", []) or []:
|
|
218
|
+
if not isinstance(finding_group, Mapping):
|
|
219
|
+
continue
|
|
220
|
+
path = str(finding_group.get("path") or "")
|
|
221
|
+
for index, text in enumerate(finding_group.get("concerns", []) or []):
|
|
222
|
+
text = str(text)
|
|
223
|
+
yield {
|
|
224
|
+
"id": _finding_id(
|
|
225
|
+
repo=repo,
|
|
226
|
+
pr_number=pr_number,
|
|
227
|
+
head_sha=head_sha,
|
|
228
|
+
profile_id=profile_id,
|
|
229
|
+
severity="CONCERN",
|
|
230
|
+
path=path,
|
|
231
|
+
index=index,
|
|
232
|
+
text=text,
|
|
233
|
+
),
|
|
234
|
+
"profile_id": profile_id,
|
|
235
|
+
"repo": repo,
|
|
236
|
+
"pr_number": pr_number,
|
|
237
|
+
"head_sha": head_sha,
|
|
238
|
+
"severity": "CONCERN",
|
|
239
|
+
"path": path,
|
|
240
|
+
"text": text,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def build_calibration_report(
|
|
245
|
+
summaries: Iterable[Path],
|
|
246
|
+
*,
|
|
247
|
+
dispositions: Mapping[str, str] | None = None,
|
|
248
|
+
) -> dict[str, Any]:
|
|
249
|
+
dispositions = dispositions or {}
|
|
250
|
+
profiles: dict[str, ProfileStats] = {}
|
|
251
|
+
findings: list[dict[str, Any]] = []
|
|
252
|
+
sources: list[str] = []
|
|
253
|
+
|
|
254
|
+
for summary_path in summaries:
|
|
255
|
+
payload = _load_json(summary_path)
|
|
256
|
+
sources.append(str(summary_path))
|
|
257
|
+
if payload.get("mode") != "local-llm-bakeoff":
|
|
258
|
+
raise ValueError(f"{summary_path} is not a local-llm-bakeoff summary")
|
|
259
|
+
runs = payload.get("runs")
|
|
260
|
+
if not isinstance(runs, list):
|
|
261
|
+
raise ValueError(f"{summary_path} must include a runs list")
|
|
262
|
+
for run in runs:
|
|
263
|
+
if not isinstance(run, Mapping):
|
|
264
|
+
continue
|
|
265
|
+
profile_id = str(run.get("profile_id") or "")
|
|
266
|
+
if not profile_id:
|
|
267
|
+
continue
|
|
268
|
+
stats = profiles.setdefault(profile_id, ProfileStats(profile_id))
|
|
269
|
+
stats.record_run(run)
|
|
270
|
+
for finding in iter_run_findings(payload, run):
|
|
271
|
+
disposition = dispositions.get(finding["id"], "unknown")
|
|
272
|
+
finding["disposition"] = disposition
|
|
273
|
+
stats.dispositions[disposition] += 1
|
|
274
|
+
findings.append(finding)
|
|
275
|
+
|
|
276
|
+
data = {
|
|
277
|
+
"mode": "local-llm-calibration",
|
|
278
|
+
"sources": sources,
|
|
279
|
+
"profiles": {
|
|
280
|
+
profile_id: profiles[profile_id].to_dict()
|
|
281
|
+
for profile_id in sorted(profiles)
|
|
282
|
+
},
|
|
283
|
+
"finding_count": len(findings),
|
|
284
|
+
"findings": findings,
|
|
285
|
+
"recommendations": _recommendations(profiles.values()),
|
|
286
|
+
}
|
|
287
|
+
return data
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def build_disposition_template(report: Mapping[str, Any]) -> dict[str, Any]:
|
|
291
|
+
"""Return a stable human-adjudication template for report findings."""
|
|
292
|
+
|
|
293
|
+
template: dict[str, Any] = {}
|
|
294
|
+
findings = report.get("findings", [])
|
|
295
|
+
if not isinstance(findings, list):
|
|
296
|
+
return template
|
|
297
|
+
for finding in findings:
|
|
298
|
+
if not isinstance(finding, Mapping):
|
|
299
|
+
continue
|
|
300
|
+
finding_id = str(finding.get("id") or "")
|
|
301
|
+
if not finding_id:
|
|
302
|
+
continue
|
|
303
|
+
template[finding_id] = {
|
|
304
|
+
"disposition": str(finding.get("disposition") or "unknown"),
|
|
305
|
+
"profile_id": str(finding.get("profile_id") or ""),
|
|
306
|
+
"repo": str(finding.get("repo") or ""),
|
|
307
|
+
"pr_number": finding.get("pr_number", ""),
|
|
308
|
+
"head_sha": str(finding.get("head_sha") or ""),
|
|
309
|
+
"severity": str(finding.get("severity") or ""),
|
|
310
|
+
"path": str(finding.get("path") or ""),
|
|
311
|
+
"text": str(finding.get("text") or ""),
|
|
312
|
+
}
|
|
313
|
+
return template
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def write_disposition_template(
|
|
317
|
+
path: Path,
|
|
318
|
+
report: Mapping[str, Any],
|
|
319
|
+
*,
|
|
320
|
+
force: bool = False,
|
|
321
|
+
) -> None:
|
|
322
|
+
if path.exists() and not force:
|
|
323
|
+
raise ValueError(f"refusing to overwrite existing disposition file: {path}")
|
|
324
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
325
|
+
payload = build_disposition_template(report)
|
|
326
|
+
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _recommendations(stats: Iterable[ProfileStats]) -> list[str]:
|
|
330
|
+
recommendations: list[str] = []
|
|
331
|
+
for profile in sorted(stats, key=lambda item: item.profile_id):
|
|
332
|
+
if profile.files_reviewed and profile.parse_failure_count:
|
|
333
|
+
rate = profile.parse_failure_count / profile.files_reviewed
|
|
334
|
+
if rate >= 0.2:
|
|
335
|
+
recommendations.append(
|
|
336
|
+
f"{profile.profile_id}: high parse-failure rate ({rate:.0%}); keep informational and tighten prompt/output repair before promotion."
|
|
337
|
+
)
|
|
338
|
+
false_blockers = profile.dispositions.get("false_positive", 0) + profile.dispositions.get("noise", 0)
|
|
339
|
+
true_or_useful = profile.dispositions.get("true_positive", 0) + profile.dispositions.get("useful", 0)
|
|
340
|
+
if false_blockers and false_blockers > true_or_useful:
|
|
341
|
+
recommendations.append(
|
|
342
|
+
f"{profile.profile_id}: more adjudicated noise than useful findings; do not use as merge authority yet."
|
|
343
|
+
)
|
|
344
|
+
if not recommendations:
|
|
345
|
+
recommendations.append(
|
|
346
|
+
"Keep local LLM lanes informational until multiple PRs have human dispositions for blocker and concern findings."
|
|
347
|
+
)
|
|
348
|
+
return recommendations
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def render_calibration_text(report: Mapping[str, Any]) -> str:
|
|
352
|
+
lines = [
|
|
353
|
+
"Local LLM calibration report",
|
|
354
|
+
"",
|
|
355
|
+
"Profiles:",
|
|
356
|
+
]
|
|
357
|
+
profiles = report.get("profiles", {})
|
|
358
|
+
if isinstance(profiles, Mapping):
|
|
359
|
+
for profile_id, stats in profiles.items():
|
|
360
|
+
if not isinstance(stats, Mapping):
|
|
361
|
+
continue
|
|
362
|
+
verdicts = ", ".join(
|
|
363
|
+
f"{key}={value}"
|
|
364
|
+
for key, value in sorted((stats.get("verdicts") or {}).items())
|
|
365
|
+
) or "none"
|
|
366
|
+
lines.extend(
|
|
367
|
+
[
|
|
368
|
+
f"- {profile_id} ({stats.get('model', '')})",
|
|
369
|
+
f" runs: {stats.get('runs', 0)}, files: {stats.get('files_reviewed', 0)}, verdicts: {verdicts}",
|
|
370
|
+
f" blockers: {stats.get('blocker_file_count', 0)}, concerns: {stats.get('concern_file_count', 0)}",
|
|
371
|
+
f" parse failures: {stats.get('parse_failure_count', 0)}, json repair used: {stats.get('json_repair_used_count', 0)}",
|
|
372
|
+
f" avg runtime: {stats.get('duration_seconds_average', 0)}s",
|
|
373
|
+
]
|
|
374
|
+
)
|
|
375
|
+
dispositions = stats.get("dispositions") or {}
|
|
376
|
+
if dispositions:
|
|
377
|
+
lines.append(
|
|
378
|
+
" dispositions: "
|
|
379
|
+
+ ", ".join(f"{key}={value}" for key, value in sorted(dispositions.items()))
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
lines.extend(["", "Recommendations:"])
|
|
383
|
+
for recommendation in report.get("recommendations", []) or []:
|
|
384
|
+
lines.append(f"- {recommendation}")
|
|
385
|
+
|
|
386
|
+
finding_count = report.get("finding_count", 0)
|
|
387
|
+
lines.extend(["", f"Findings indexed for adjudication: {finding_count}"])
|
|
388
|
+
return "\n".join(lines) + "\n"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def main(argv: list[str] | None = None) -> int:
|
|
392
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
393
|
+
parser.add_argument("summaries", nargs="+", type=Path)
|
|
394
|
+
parser.add_argument(
|
|
395
|
+
"--dispositions",
|
|
396
|
+
type=Path,
|
|
397
|
+
default=None,
|
|
398
|
+
help="Optional JSON mapping of finding id to true_positive/false_positive/useful/noise.",
|
|
399
|
+
)
|
|
400
|
+
parser.add_argument(
|
|
401
|
+
"--write-disposition-template",
|
|
402
|
+
type=Path,
|
|
403
|
+
default=None,
|
|
404
|
+
help=(
|
|
405
|
+
"Write a JSON adjudication template keyed by finding id. Fill in "
|
|
406
|
+
"disposition values, then pass it back via --dispositions."
|
|
407
|
+
),
|
|
408
|
+
)
|
|
409
|
+
parser.add_argument(
|
|
410
|
+
"--force",
|
|
411
|
+
action="store_true",
|
|
412
|
+
help="Allow --write-disposition-template to overwrite an existing file.",
|
|
413
|
+
)
|
|
414
|
+
parser.add_argument("--json", action="store_true")
|
|
415
|
+
args = parser.parse_args(argv)
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
report = build_calibration_report(
|
|
419
|
+
args.summaries,
|
|
420
|
+
dispositions=load_dispositions(args.dispositions),
|
|
421
|
+
)
|
|
422
|
+
if args.write_disposition_template is not None:
|
|
423
|
+
write_disposition_template(
|
|
424
|
+
args.write_disposition_template,
|
|
425
|
+
report,
|
|
426
|
+
force=args.force,
|
|
427
|
+
)
|
|
428
|
+
report["disposition_template_path"] = str(args.write_disposition_template)
|
|
429
|
+
except ValueError as exc:
|
|
430
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
431
|
+
return 1
|
|
432
|
+
|
|
433
|
+
if args.json:
|
|
434
|
+
print(json.dumps(report, indent=2, sort_keys=True))
|
|
435
|
+
else:
|
|
436
|
+
print(render_calibration_text(report), end="")
|
|
437
|
+
return 0
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
if __name__ == "__main__":
|
|
441
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Named local/private LLM runtime profiles for Code Mower.
|
|
3
|
+
|
|
4
|
+
Profiles are intentionally plain data. Provider-specific behavior still lives
|
|
5
|
+
in the caller and adapter layers; a profile only answers "where is the model
|
|
6
|
+
endpoint, what model id should be sent, and what budgets are reasonable?"
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class LocalLlmProfile:
|
|
17
|
+
profile_id: str
|
|
18
|
+
description: str
|
|
19
|
+
api_base: str
|
|
20
|
+
model: str
|
|
21
|
+
endpoint: str = ""
|
|
22
|
+
api_key: str = "EMPTY"
|
|
23
|
+
context_window: int = 128_000
|
|
24
|
+
max_files: int = 25
|
|
25
|
+
max_file_bytes: int = 60_000
|
|
26
|
+
http_timeout: int = 900
|
|
27
|
+
informational: bool = True
|
|
28
|
+
|
|
29
|
+
def as_dict(self) -> dict[str, Any]:
|
|
30
|
+
return asdict(self)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
LOCAL_LLM_PROFILES: dict[str, LocalLlmProfile] = {
|
|
34
|
+
"qwen3-coder-next-lmstudio": LocalLlmProfile(
|
|
35
|
+
profile_id="qwen3-coder-next-lmstudio",
|
|
36
|
+
description=(
|
|
37
|
+
"Qwen3 Coder Next through LM Studio's OpenAI-compatible local endpoint."
|
|
38
|
+
),
|
|
39
|
+
api_base="http://localhost:1234/v1",
|
|
40
|
+
model="qwen/qwen3-coder-next",
|
|
41
|
+
endpoint="lmstudio",
|
|
42
|
+
),
|
|
43
|
+
"gemma4-ollama": LocalLlmProfile(
|
|
44
|
+
profile_id="gemma4-ollama",
|
|
45
|
+
description="Gemma 4 through Ollama's OpenAI-compatible local endpoint.",
|
|
46
|
+
api_base="http://localhost:11434/v1",
|
|
47
|
+
model="gemma4:e4b-mlx",
|
|
48
|
+
endpoint="ollama",
|
|
49
|
+
),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def list_profiles() -> list[LocalLlmProfile]:
|
|
54
|
+
return [LOCAL_LLM_PROFILES[key] for key in sorted(LOCAL_LLM_PROFILES)]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def profile_ids() -> tuple[str, ...]:
|
|
58
|
+
return tuple(sorted(LOCAL_LLM_PROFILES))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_profile(profile_id: str) -> LocalLlmProfile:
|
|
62
|
+
try:
|
|
63
|
+
return LOCAL_LLM_PROFILES[profile_id]
|
|
64
|
+
except KeyError as exc:
|
|
65
|
+
known = ", ".join(profile_ids())
|
|
66
|
+
raise KeyError(f"unknown local LLM profile {profile_id!r}; known profiles: {known}") from exc
|