skelpr 0.1.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.
- skelpr/__init__.py +5 -0
- skelpr/agents/__init__.py +0 -0
- skelpr/agents/bug_fix_agent.py +251 -0
- skelpr/agents/patch_agent.py +36 -0
- skelpr/agents/pr_review_agent.py +42 -0
- skelpr/agents/repo_qa_agent.py +36 -0
- skelpr/agents/test_generation_agent.py +46 -0
- skelpr/cli/__init__.py +0 -0
- skelpr/cli/commands/__init__.py +0 -0
- skelpr/cli/commands/ask.py +76 -0
- skelpr/cli/commands/chat.py +218 -0
- skelpr/cli/commands/context.py +143 -0
- skelpr/cli/commands/fix.py +120 -0
- skelpr/cli/commands/github.py +352 -0
- skelpr/cli/commands/index.py +30 -0
- skelpr/cli/commands/init.py +168 -0
- skelpr/cli/commands/install_mcp.py +449 -0
- skelpr/cli/commands/register_agent.py +168 -0
- skelpr/cli/commands/review.py +73 -0
- skelpr/cli/commands/serve.py +26 -0
- skelpr/cli/commands/setup.py +253 -0
- skelpr/cli/commands/uninstall_mcp.py +118 -0
- skelpr/cli/commands/validate.py +69 -0
- skelpr/cli/console.py +31 -0
- skelpr/cli/main.py +100 -0
- skelpr/config/__init__.py +0 -0
- skelpr/config/defaults.py +96 -0
- skelpr/config/loader.py +181 -0
- skelpr/config/schema.py +205 -0
- skelpr/db/__init__.py +0 -0
- skelpr/db/migrations/0001_initial.sql +177 -0
- skelpr/db/migrations/0002_add_tenant_columns.sql +148 -0
- skelpr/db/postgres.py +474 -0
- skelpr/db/repositories.py +592 -0
- skelpr/embeddings/__init__.py +0 -0
- skelpr/embeddings/embedder.py +57 -0
- skelpr/embeddings/local_embedding_client.py +139 -0
- skelpr/engine.py +527 -0
- skelpr/indexers/__init__.py +0 -0
- skelpr/indexers/chunker.py +160 -0
- skelpr/indexers/docs_indexer.py +59 -0
- skelpr/indexers/graphify_adapter.py +213 -0
- skelpr/indexers/hashing.py +17 -0
- skelpr/indexers/repo_scanner.py +113 -0
- skelpr/indexers/scip_indexer.py +107 -0
- skelpr/indexers/tree_sitter_indexer.py +458 -0
- skelpr/integrations/__init__.py +0 -0
- skelpr/integrations/common.py +37 -0
- skelpr/integrations/github/__init__.py +0 -0
- skelpr/integrations/github/checks.py +50 -0
- skelpr/integrations/github/comments.py +54 -0
- skelpr/integrations/github/webhook.py +54 -0
- skelpr/integrations/gitlab/__init__.py +0 -0
- skelpr/integrations/gitlab/comments.py +50 -0
- skelpr/integrations/gitlab/webhook.py +39 -0
- skelpr/integrations/mcp_server.py +928 -0
- skelpr/models/__init__.py +0 -0
- skelpr/models/constants.py +122 -0
- skelpr/models/local_llm_client.py +282 -0
- skelpr/models/model_router.py +66 -0
- skelpr/models/prompt_templates.py +105 -0
- skelpr/models/remote_llm_client.py +191 -0
- skelpr/patches/__init__.py +0 -0
- skelpr/patches/diff_generator.py +438 -0
- skelpr/patches/patch_validator.py +54 -0
- skelpr/patches/patch_writer.py +46 -0
- skelpr/plugins/README.md +124 -0
- skelpr/plugins/__init__.py +103 -0
- skelpr/plugins/__main__.py +39 -0
- skelpr/plugins/api.py +378 -0
- skelpr/plugins/server.py +43 -0
- skelpr/py.typed +0 -0
- skelpr/retrieval/__init__.py +0 -0
- skelpr/retrieval/context_builder.py +72 -0
- skelpr/retrieval/graph_retriever.py +48 -0
- skelpr/retrieval/hybrid_retriever.py +337 -0
- skelpr/retrieval/lexical_retriever.py +395 -0
- skelpr/retrieval/reranker.py +114 -0
- skelpr/retrieval/symbol_retriever.py +44 -0
- skelpr/retrieval/types.py +42 -0
- skelpr/retrieval/vector_retriever.py +307 -0
- skelpr/review/__init__.py +0 -0
- skelpr/review/confidence_filter.py +64 -0
- skelpr/review/diff_parser.py +107 -0
- skelpr/review/finding_schema.py +94 -0
- skelpr/review/review_formatter.py +88 -0
- skelpr/review/review_gate.py +246 -0
- skelpr/review/severity_ranker.py +16 -0
- skelpr/sandbox/Dockerfile.runner +88 -0
- skelpr/sandbox/__init__.py +0 -0
- skelpr/sandbox/command_runner.py +95 -0
- skelpr/sandbox/docker_runner.py +81 -0
- skelpr/sandbox/security.py +34 -0
- skelpr/sandbox/validation_runner.py +119 -0
- skelpr/server/__init__.py +0 -0
- skelpr/server/app.py +55 -0
- skelpr/server/deps.py +202 -0
- skelpr/server/routes/__init__.py +0 -0
- skelpr/server/routes/ask.py +24 -0
- skelpr/server/routes/fix.py +43 -0
- skelpr/server/routes/health.py +16 -0
- skelpr/server/routes/repos.py +24 -0
- skelpr/server/routes/review.py +26 -0
- skelpr/server/routes/validate.py +21 -0
- skelpr/server/schemas.py +72 -0
- skelpr/server/security.py +232 -0
- skelpr/utils/__init__.py +0 -0
- skelpr/utils/errors.py +51 -0
- skelpr/utils/files.py +60 -0
- skelpr/utils/git.py +103 -0
- skelpr/utils/logging.py +91 -0
- skelpr/utils/paths.py +49 -0
- skelpr/utils/tracing.py +344 -0
- skelpr-0.1.0.dist-info/METADATA +458 -0
- skelpr-0.1.0.dist-info/RECORD +121 -0
- skelpr-0.1.0.dist-info/WHEEL +5 -0
- skelpr-0.1.0.dist-info/entry_points.txt +4 -0
- skelpr-0.1.0.dist-info/licenses/LICENSE +201 -0
- skelpr-0.1.0.dist-info/licenses/NOTICE +24 -0
- skelpr-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +48 -0
- skelpr-0.1.0.dist-info/top_level.txt +1 -0
skelpr/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Bug-fix agent: issue -> grounded root-cause hypothesis + minimal edits.
|
|
2
|
+
|
|
3
|
+
Flow:
|
|
4
|
+
1. Retrieve context from the hybrid index (chunks, symbols, graph).
|
|
5
|
+
2. Ask the model for a diagnosis + edits (first pass).
|
|
6
|
+
3. If edits are missing or any find-string doesn't match the real file,
|
|
7
|
+
read the actual file(s) in full and retry once with exact source injected.
|
|
8
|
+
4. If the model returned empty/garbage, extract candidate files from the
|
|
9
|
+
issue text and inject them for the retry pass.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ..models.model_router import ModelRouter, Task
|
|
18
|
+
from ..models.prompt_templates import bug_fix_messages
|
|
19
|
+
from ..patches.diff_generator import Edit
|
|
20
|
+
from ..retrieval.context_builder import build_context, context_is_sufficient
|
|
21
|
+
from ..retrieval.hybrid_retriever import HybridRetriever
|
|
22
|
+
from ..utils.errors import ModelError
|
|
23
|
+
from ..utils.logging import get_logger
|
|
24
|
+
|
|
25
|
+
log = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
_ERR_TERM = re.compile(r"[A-Za-z_][A-Za-z0-9_]{3,}")
|
|
28
|
+
_TRACE_FILE = re.compile(r'File "([^"]+)", line (\d+)')
|
|
29
|
+
|
|
30
|
+
# How many chars of each real file to inject on the retry pass.
|
|
31
|
+
_MAX_FILE_INJECT_CHARS = 12_000
|
|
32
|
+
|
|
33
|
+
# File extension pattern used to find candidate files in the issue text.
|
|
34
|
+
_FILE_PATTERN = re.compile(
|
|
35
|
+
r"\b([\w/\\.-]+\.(?:js|ts|tsx|jsx|py|css|scss|sass|java|go|rs|vue|svelte))\b"
|
|
36
|
+
)
|
|
37
|
+
# CamelCase component/class names (e.g. CategoryManagement, AdminPanel)
|
|
38
|
+
_COMPONENT_PATTERN = re.compile(r"\b([A-Z][a-zA-Z]{3,})\b")
|
|
39
|
+
_CODE_EXTENSIONS = {".js", ".ts", ".tsx", ".jsx", ".py", ".css", ".scss",
|
|
40
|
+
".sass", ".java", ".go", ".rs", ".vue", ".svelte"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _error_terms(issue: str, stack_trace: str | None) -> list[str]:
|
|
44
|
+
blob = f"{issue}\n{stack_trace or ''}"
|
|
45
|
+
quoted = re.findall(r"[\"'`]([^\"'`]{3,60})[\"'`]", blob)
|
|
46
|
+
errors = re.findall(r"\b(\w*(?:Error|Exception|Warning))\b", blob)
|
|
47
|
+
idents: list[str] = []
|
|
48
|
+
for m in _ERR_TERM.finditer(issue):
|
|
49
|
+
idents.append(m.group(0))
|
|
50
|
+
seen: list[str] = []
|
|
51
|
+
for t in errors + quoted + idents:
|
|
52
|
+
t = t.strip()
|
|
53
|
+
if t and t not in seen:
|
|
54
|
+
seen.append(t)
|
|
55
|
+
return seen[:12]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _parse_edits(raw: dict) -> list[Edit]:
|
|
59
|
+
return [
|
|
60
|
+
Edit(file=e.get("file", ""), find=e.get("find", ""),
|
|
61
|
+
replace=e.get("replace", ""), reason=e.get("reason", ""))
|
|
62
|
+
for e in raw.get("edits", []) if isinstance(e, dict)
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _extract_files_from_issue(issue: str, root: Path) -> list[str]:
|
|
67
|
+
"""Find files that exist in the repo and are mentioned in the issue text.
|
|
68
|
+
|
|
69
|
+
Looks for:
|
|
70
|
+
- Explicit file paths with extensions (e.g. app/containers/Foo/index.js)
|
|
71
|
+
- CamelCase component/class names (e.g. CategoryManagement -> CategoryManagement/index.js)
|
|
72
|
+
"""
|
|
73
|
+
result: list[str] = []
|
|
74
|
+
|
|
75
|
+
# 1. Explicit file references with extensions
|
|
76
|
+
for m in _FILE_PATTERN.finditer(issue):
|
|
77
|
+
name = Path(m.group(1)).name
|
|
78
|
+
for p in root.rglob(name):
|
|
79
|
+
try:
|
|
80
|
+
rel = str(p.relative_to(root)).replace("\\", "/")
|
|
81
|
+
except ValueError:
|
|
82
|
+
continue
|
|
83
|
+
if rel not in result:
|
|
84
|
+
result.append(rel)
|
|
85
|
+
|
|
86
|
+
# 2. CamelCase component/class names — search for files whose path contains the name
|
|
87
|
+
for m in _COMPONENT_PATTERN.finditer(issue):
|
|
88
|
+
name = m.group(1)
|
|
89
|
+
for p in root.rglob(f"*{name}*"):
|
|
90
|
+
if p.suffix in _CODE_EXTENSIONS:
|
|
91
|
+
try:
|
|
92
|
+
rel = str(p.relative_to(root)).replace("\\", "/")
|
|
93
|
+
except ValueError:
|
|
94
|
+
continue
|
|
95
|
+
if rel not in result:
|
|
96
|
+
result.append(rel)
|
|
97
|
+
|
|
98
|
+
return result[:4]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _files_needing_injection(edits: list[Edit], affected: list[str],
|
|
102
|
+
root: Path) -> list[str]:
|
|
103
|
+
"""Return files that need full-source injection:
|
|
104
|
+
- any file in affected_files the model didn't produce edits for, OR
|
|
105
|
+
- any file whose find-string doesn't exist in the real file.
|
|
106
|
+
"""
|
|
107
|
+
needed: list[str] = []
|
|
108
|
+
|
|
109
|
+
edit_files = {e.file for e in edits if e.file}
|
|
110
|
+
for f in affected:
|
|
111
|
+
if f not in edit_files and f not in needed:
|
|
112
|
+
needed.append(f)
|
|
113
|
+
|
|
114
|
+
for e in edits:
|
|
115
|
+
if not e.find or not e.file or e.file in needed:
|
|
116
|
+
continue
|
|
117
|
+
fp = root / e.file
|
|
118
|
+
if not fp.exists():
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
text = fp.read_text(encoding="utf-8", errors="replace")
|
|
122
|
+
except OSError:
|
|
123
|
+
continue
|
|
124
|
+
# Simple check if find string exists (exact match only)
|
|
125
|
+
# The patching system will handle fuzzy matching if needed
|
|
126
|
+
if e.find not in text:
|
|
127
|
+
needed.append(e.file)
|
|
128
|
+
|
|
129
|
+
return needed
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _inject_file_content(root: Path, files: list[str],
|
|
133
|
+
base_context: str) -> str:
|
|
134
|
+
parts = [base_context]
|
|
135
|
+
for rel in files:
|
|
136
|
+
fp = root / rel
|
|
137
|
+
try:
|
|
138
|
+
src = fp.read_text(encoding="utf-8", errors="replace")
|
|
139
|
+
except OSError:
|
|
140
|
+
continue
|
|
141
|
+
snippet = src[:_MAX_FILE_INJECT_CHARS]
|
|
142
|
+
if len(src) > _MAX_FILE_INJECT_CHARS:
|
|
143
|
+
snippet += f"\n... (file truncated at {_MAX_FILE_INJECT_CHARS} chars)"
|
|
144
|
+
parts.append(
|
|
145
|
+
f"\n\n# FULL SOURCE of {rel} "
|
|
146
|
+
f"(your find-strings MUST be verbatim substrings from this):\n{snippet}"
|
|
147
|
+
)
|
|
148
|
+
return "".join(parts)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _raw_is_empty(raw: dict) -> bool:
|
|
152
|
+
"""True when the model returned a dict with no useful diagnosis content."""
|
|
153
|
+
return not (raw.get("root_cause") or raw.get("plan") or
|
|
154
|
+
raw.get("edits") or raw.get("affected_files"))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class Diagnosis:
|
|
159
|
+
root_cause: str
|
|
160
|
+
affected_files: list[str]
|
|
161
|
+
plan: str
|
|
162
|
+
risk: str
|
|
163
|
+
edits: list[Edit] = field(default_factory=list)
|
|
164
|
+
sufficient_context: bool = True
|
|
165
|
+
backends: dict[str, str] = field(default_factory=dict)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class BugFixAgent:
|
|
169
|
+
def __init__(self, retriever: HybridRetriever, router: ModelRouter,
|
|
170
|
+
max_context_tokens: int = 6000):
|
|
171
|
+
self._retriever = retriever
|
|
172
|
+
self._router = router
|
|
173
|
+
self._max_ctx = max_context_tokens
|
|
174
|
+
|
|
175
|
+
def _safe_complete_json(self, task: Task, messages: list) -> dict:
|
|
176
|
+
"""Call complete_json and always return a dict (never raises)."""
|
|
177
|
+
try:
|
|
178
|
+
result = self._router.complete_json(task, messages)
|
|
179
|
+
if isinstance(result, dict):
|
|
180
|
+
return result
|
|
181
|
+
if isinstance(result, list):
|
|
182
|
+
# Model returned a bare list — treat it as affected_files so
|
|
183
|
+
# the retry logic can inject those files.
|
|
184
|
+
log.warning("complete_json returned list; treating as affected_files: %s",
|
|
185
|
+
result[:4])
|
|
186
|
+
return {"affected_files": [r for r in result if isinstance(r, str)]}
|
|
187
|
+
log.warning("complete_json returned unexpected type %s; ignoring", type(result))
|
|
188
|
+
return {}
|
|
189
|
+
except ModelError as exc:
|
|
190
|
+
log.warning("complete_json ModelError: %s", exc)
|
|
191
|
+
return {}
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
log.warning("complete_json unexpected error: %s", exc)
|
|
194
|
+
return {}
|
|
195
|
+
|
|
196
|
+
def diagnose(self, issue: str, stack_trace: str | None = None,
|
|
197
|
+
root: Path | None = None) -> Diagnosis:
|
|
198
|
+
terms = _error_terms(issue, stack_trace)
|
|
199
|
+
trace_files = [m.group(1) for m in _TRACE_FILE.finditer(stack_trace or "")]
|
|
200
|
+
|
|
201
|
+
chunks = self._retriever.retrieve_for_diff(
|
|
202
|
+
changed_files=trace_files, changed_symbols=terms,
|
|
203
|
+
error_terms=terms, top_k=14)
|
|
204
|
+
chunks += self._retriever.retrieve_for_query(issue, top_k=6)
|
|
205
|
+
sufficient = context_is_sufficient(chunks)
|
|
206
|
+
ctx = build_context(
|
|
207
|
+
chunks, max_tokens=self._max_ctx,
|
|
208
|
+
header="# Code context for diagnosis (copy find-strings verbatim)")
|
|
209
|
+
|
|
210
|
+
# --- first pass ---
|
|
211
|
+
raw = self._safe_complete_json(
|
|
212
|
+
Task.fix, bug_fix_messages(issue, ctx.text, stack_trace))
|
|
213
|
+
edits = _parse_edits(raw)
|
|
214
|
+
affected = list(raw.get("affected_files", []) or [])
|
|
215
|
+
|
|
216
|
+
# --- retry: inject full file source ---
|
|
217
|
+
# Triggered when:
|
|
218
|
+
# (a) model returned empty/garbage -> inject files from issue text
|
|
219
|
+
# (b) model listed affected_files but no edits -> inject those files
|
|
220
|
+
# (c) find-strings don't exist in real files -> inject those files
|
|
221
|
+
if root is not None:
|
|
222
|
+
if _raw_is_empty(raw):
|
|
223
|
+
to_inject = _extract_files_from_issue(issue, root)
|
|
224
|
+
log.info("First pass empty; injecting issue-mentioned files: %s", to_inject)
|
|
225
|
+
else:
|
|
226
|
+
to_inject = _files_needing_injection(edits, affected, root)
|
|
227
|
+
if not to_inject and _raw_is_empty({"edits": edits}):
|
|
228
|
+
# Got a diagnosis but no edits — inject affected files
|
|
229
|
+
to_inject = affected[:3]
|
|
230
|
+
|
|
231
|
+
if to_inject:
|
|
232
|
+
enriched = _inject_file_content(root, to_inject, ctx.text)
|
|
233
|
+
retry_issue = (
|
|
234
|
+
issue
|
|
235
|
+
+ "\n\nIMPORTANT: The complete source of the relevant files is "
|
|
236
|
+
"appended above. The new patching system will use AST-based "
|
|
237
|
+
"symbol resolution when possible, with fuzzy matching fallback."
|
|
238
|
+
)
|
|
239
|
+
raw2 = self._safe_complete_json(
|
|
240
|
+
Task.fix, bug_fix_messages(retry_issue, enriched, stack_trace))
|
|
241
|
+
if not _raw_is_empty(raw2):
|
|
242
|
+
edits2 = _parse_edits(raw2)
|
|
243
|
+
if edits2 or raw2.get("root_cause"):
|
|
244
|
+
raw, edits = raw2, edits2
|
|
245
|
+
|
|
246
|
+
return Diagnosis(
|
|
247
|
+
root_cause=str(raw.get("root_cause", "")),
|
|
248
|
+
affected_files=list(raw.get("affected_files", []) or []),
|
|
249
|
+
plan=str(raw.get("plan", "")), risk=str(raw.get("risk", "")),
|
|
250
|
+
edits=edits, sufficient_context=sufficient,
|
|
251
|
+
backends=self._retriever.backends())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Patch agent: turn diagnosis edits into a validated, minimal unified diff."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..config.schema import CommandsConfig
|
|
8
|
+
from ..patches.diff_generator import Edit, GeneratedPatch, generate_patch
|
|
9
|
+
from ..patches.patch_validator import PatchCheck, check_patch
|
|
10
|
+
from ..utils.errors import PatchError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class PatchOutcome:
|
|
15
|
+
patch: GeneratedPatch
|
|
16
|
+
check: PatchCheck
|
|
17
|
+
validation_recommendation: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PatchAgent:
|
|
21
|
+
def __init__(self, root: Path, commands: CommandsConfig):
|
|
22
|
+
self._root = root
|
|
23
|
+
self._commands = commands
|
|
24
|
+
|
|
25
|
+
def build(self, edits: list[Edit]) -> PatchOutcome:
|
|
26
|
+
if not edits:
|
|
27
|
+
raise PatchError("No edits to apply; cannot generate a patch.")
|
|
28
|
+
patch = generate_patch(self._root, edits)
|
|
29
|
+
check = check_patch(self._root, patch.unified_diff)
|
|
30
|
+
cmds = self._commands.as_mapping()
|
|
31
|
+
rec = (
|
|
32
|
+
"After applying, validate with: "
|
|
33
|
+
+ "; ".join(f"{name} (`{cmd}`)" for name, cmd in cmds.items())
|
|
34
|
+
+ ". Do not consider the fix complete until validation passes."
|
|
35
|
+
)
|
|
36
|
+
return PatchOutcome(patch=patch, check=check, validation_recommendation=rec)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""PR review agent: diff -> retrieval -> model -> filtered structured findings."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ..config.schema import ReviewConfig
|
|
5
|
+
from ..models.model_router import ModelRouter, Task
|
|
6
|
+
from ..models.prompt_templates import pr_review_messages
|
|
7
|
+
from ..retrieval.context_builder import build_context
|
|
8
|
+
from ..retrieval.hybrid_retriever import HybridRetriever
|
|
9
|
+
from ..review.confidence_filter import filter_findings
|
|
10
|
+
from ..review.diff_parser import ParsedDiff, changed_symbol_names, parse_unified_diff
|
|
11
|
+
from ..review.finding_schema import ReviewResult, parse_findings
|
|
12
|
+
from ..review.severity_ranker import rank_and_cap
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PRReviewAgent:
|
|
16
|
+
def __init__(self, retriever: HybridRetriever, router: ModelRouter,
|
|
17
|
+
review_config: ReviewConfig, max_context_tokens: int = 12000):
|
|
18
|
+
self._retriever = retriever
|
|
19
|
+
self._router = router
|
|
20
|
+
self._config = review_config
|
|
21
|
+
self._max_ctx = max_context_tokens
|
|
22
|
+
|
|
23
|
+
def review(self, diff_text: str, diff_range: str = "") -> ReviewResult:
|
|
24
|
+
parsed: ParsedDiff = parse_unified_diff(diff_text)
|
|
25
|
+
if not parsed.files:
|
|
26
|
+
return ReviewResult(diff_range=diff_range, backends=self._retriever.backends())
|
|
27
|
+
|
|
28
|
+
changed_files = parsed.changed_paths
|
|
29
|
+
changed_symbols = changed_symbol_names(parsed)
|
|
30
|
+
context_chunks = self._retriever.retrieve_for_diff(
|
|
31
|
+
changed_files=changed_files, changed_symbols=changed_symbols)
|
|
32
|
+
ctx = build_context(context_chunks, max_tokens=self._max_ctx,
|
|
33
|
+
header="# Context for the change")
|
|
34
|
+
|
|
35
|
+
raw = self._router.complete_json(Task.review, pr_review_messages(diff_text, ctx.text))
|
|
36
|
+
findings = parse_findings(raw)
|
|
37
|
+
|
|
38
|
+
kept, suppressed = filter_findings(findings, self._config, parsed)
|
|
39
|
+
ranked = rank_and_cap(kept, self._config)
|
|
40
|
+
suppressed += len(kept) - len(ranked)
|
|
41
|
+
return ReviewResult(findings=ranked, suppressed=suppressed,
|
|
42
|
+
diff_range=diff_range, backends=self._retriever.backends())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Repo Q&A agent: grounded answers with file:line citations."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from ..models.model_router import ModelRouter, Task
|
|
7
|
+
from ..models.prompt_templates import repo_qa_messages
|
|
8
|
+
from ..retrieval.context_builder import build_context, context_is_sufficient
|
|
9
|
+
from ..retrieval.hybrid_retriever import HybridRetriever
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Answer:
|
|
14
|
+
text: str
|
|
15
|
+
citations: list[str]
|
|
16
|
+
sufficient_context: bool
|
|
17
|
+
backends: dict[str, str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RepoQAAgent:
|
|
21
|
+
def __init__(self, retriever: HybridRetriever, router: ModelRouter,
|
|
22
|
+
max_context_tokens: int = 16000):
|
|
23
|
+
self._retriever = retriever
|
|
24
|
+
self._router = router
|
|
25
|
+
self._max_ctx = max_context_tokens
|
|
26
|
+
|
|
27
|
+
def ask(self, question: str, top_k: int = 12) -> Answer:
|
|
28
|
+
chunks = self._retriever.retrieve_for_query(question, top_k=top_k)
|
|
29
|
+
sufficient = context_is_sufficient(chunks)
|
|
30
|
+
ctx = build_context(chunks, max_tokens=self._max_ctx,
|
|
31
|
+
header="# Retrieved context (cite these locations)")
|
|
32
|
+
messages = repo_qa_messages(question, ctx.text, insufficient=not sufficient)
|
|
33
|
+
result = self._router.complete(Task.qa, messages)
|
|
34
|
+
return Answer(text=result.text.strip(), citations=ctx.citations,
|
|
35
|
+
sufficient_context=sufficient,
|
|
36
|
+
backends=self._retriever.backends())
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Test-generation agent: propose minimal tests for changed behavior."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from ..models.model_router import ModelRouter, Task
|
|
7
|
+
from ..models.prompt_templates import test_gen_messages
|
|
8
|
+
from ..retrieval.context_builder import build_context
|
|
9
|
+
from ..retrieval.hybrid_retriever import HybridRetriever
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ProposedTest:
|
|
14
|
+
path: str
|
|
15
|
+
content: str
|
|
16
|
+
reason: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TestProposal:
|
|
21
|
+
tests: list[ProposedTest] = field(default_factory=list)
|
|
22
|
+
backends: dict[str, str] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestGenerationAgent:
|
|
26
|
+
def __init__(self, retriever: HybridRetriever, router: ModelRouter,
|
|
27
|
+
max_context_tokens: int = 12000):
|
|
28
|
+
self._retriever = retriever
|
|
29
|
+
self._router = router
|
|
30
|
+
self._max_ctx = max_context_tokens
|
|
31
|
+
|
|
32
|
+
def generate(self, change_summary: str, changed_files: list[str],
|
|
33
|
+
changed_symbols: list[str]) -> TestProposal:
|
|
34
|
+
chunks = self._retriever.retrieve_for_diff(
|
|
35
|
+
changed_files=changed_files, changed_symbols=changed_symbols, top_k=10)
|
|
36
|
+
ctx = build_context(chunks, max_tokens=self._max_ctx,
|
|
37
|
+
header="# Code under test + existing tests")
|
|
38
|
+
raw = self._router.complete_json(
|
|
39
|
+
Task.test_gen, test_gen_messages(change_summary, ctx.text))
|
|
40
|
+
raw = raw if isinstance(raw, dict) else {}
|
|
41
|
+
tests = [
|
|
42
|
+
ProposedTest(path=t.get("path", ""), content=t.get("content", ""),
|
|
43
|
+
reason=t.get("reason", ""))
|
|
44
|
+
for t in raw.get("tests", []) if isinstance(t, dict)
|
|
45
|
+
]
|
|
46
|
+
return TestProposal(tests=tests, backends=self._retriever.backends())
|
skelpr/cli/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""`skelpr ask` - grounded Q&A over the repo."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ...engine import Engine
|
|
10
|
+
from ...utils.errors import ModelError
|
|
11
|
+
from ...utils.tracing import (
|
|
12
|
+
build_retrieval_meta,
|
|
13
|
+
build_timing,
|
|
14
|
+
traced_operation,
|
|
15
|
+
)
|
|
16
|
+
from ..console import console, fail
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run(
|
|
20
|
+
question: str = typer.Argument(..., help="Your question about the repository."),
|
|
21
|
+
top_k: int = typer.Option(12, "--top-k", help="Number of context chunks."),
|
|
22
|
+
path: Path = typer.Option(None, "--path"),
|
|
23
|
+
) -> None:
|
|
24
|
+
t0 = time.perf_counter()
|
|
25
|
+
try:
|
|
26
|
+
engine = Engine.from_path(path)
|
|
27
|
+
except Exception as exc:
|
|
28
|
+
fail(str(exc))
|
|
29
|
+
|
|
30
|
+
with traced_operation("skelpr_ask", engine, {"query": question, "top_k": top_k}) as span:
|
|
31
|
+
try:
|
|
32
|
+
t_retrieval = time.perf_counter()
|
|
33
|
+
answer = engine.ask(question, top_k=top_k)
|
|
34
|
+
retrieval_ms = (time.perf_counter() - t_retrieval) * 1000
|
|
35
|
+
|
|
36
|
+
# Get retrieval metrics if available
|
|
37
|
+
rm = getattr(engine, '_last_retriever', None)
|
|
38
|
+
rm = rm.last_retrieval_metrics if rm else None
|
|
39
|
+
|
|
40
|
+
# Build full metadata
|
|
41
|
+
retrieval_meta = build_retrieval_meta(rm, [])
|
|
42
|
+
timing = build_timing(rm, 0.0, retrieval_ms, 0.0, (time.perf_counter() - t0) * 1000)
|
|
43
|
+
|
|
44
|
+
span.finish(
|
|
45
|
+
output={
|
|
46
|
+
"text": answer.text,
|
|
47
|
+
"sufficient_context": answer.sufficient_context,
|
|
48
|
+
"citations": answer.citations,
|
|
49
|
+
"final_context_char_count": len(answer.text),
|
|
50
|
+
"context_token_estimate": len(answer.text) // 4,
|
|
51
|
+
},
|
|
52
|
+
metadata={
|
|
53
|
+
"success": True,
|
|
54
|
+
"top_k": top_k,
|
|
55
|
+
**retrieval_meta,
|
|
56
|
+
**timing,
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
except ModelError as exc:
|
|
60
|
+
span.finish(metadata={"success": False, "error_type": "ModelError", "error": str(exc)})
|
|
61
|
+
fail(str(exc), code=3)
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
span.finish(metadata={"success": False, "error_type": "Exception", "error": str(exc)})
|
|
64
|
+
fail(str(exc))
|
|
65
|
+
|
|
66
|
+
elapsed = time.perf_counter() - t0
|
|
67
|
+
from rich.panel import Panel
|
|
68
|
+
|
|
69
|
+
console.print(f"[dim] {elapsed:.1f}s[/dim]")
|
|
70
|
+
console.print(Panel(answer.text, title="Answer", border_style="cyan"))
|
|
71
|
+
if not answer.sufficient_context:
|
|
72
|
+
console.print("[yellow]Note:[/yellow] retrieved context was thin; "
|
|
73
|
+
"answer may be incomplete.")
|
|
74
|
+
if answer.citations:
|
|
75
|
+
console.print("[dim]Context:[/dim] " + ", ".join(answer.citations))
|
|
76
|
+
engine.close()
|