dryscope 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.
- dryscope/__init__.py +3 -0
- dryscope/benchmark.py +374 -0
- dryscope/cache.py +175 -0
- dryscope/cli.py +918 -0
- dryscope/code/__init__.py +1 -0
- dryscope/code/embedder.py +110 -0
- dryscope/code/normalizer.py +306 -0
- dryscope/code/parser.py +338 -0
- dryscope/code/policy.py +47 -0
- dryscope/code/profiles.py +129 -0
- dryscope/code/reporter.py +246 -0
- dryscope/code/treesitter.py +71 -0
- dryscope/code/verifier.py +280 -0
- dryscope/config.py +424 -0
- dryscope/docs/__init__.py +1 -0
- dryscope/docs/chunker.py +327 -0
- dryscope/docs/coding.py +516 -0
- dryscope/docs/embeddings.py +329 -0
- dryscope/docs/models.py +110 -0
- dryscope/docs/pipeline.py +984 -0
- dryscope/docs/report.py +2063 -0
- dryscope/docs/taxonomy.py +959 -0
- dryscope/docs/topics.py +578 -0
- dryscope/help_topics.py +286 -0
- dryscope/llm_backend.py +190 -0
- dryscope/run_store.py +162 -0
- dryscope/similarity.py +151 -0
- dryscope/skill/SKILL.md +171 -0
- dryscope/skill/__init__.py +0 -0
- dryscope/terminology.py +27 -0
- dryscope/unified_report.py +198 -0
- dryscope-0.1.0.dist-info/METADATA +718 -0
- dryscope-0.1.0.dist-info/RECORD +36 -0
- dryscope-0.1.0.dist-info/WHEEL +4 -0
- dryscope-0.1.0.dist-info/entry_points.txt +2 -0
- dryscope-0.1.0.dist-info/licenses/LICENSE +21 -0
dryscope/__init__.py
ADDED
dryscope/benchmark.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Helpers for running and scoring public dryscope benchmarks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
ACTIONABLE_CODE_LABELS = {"real_refactor_candidate"}
|
|
10
|
+
NON_ACTIONABLE_CODE_LABELS = {"not_worth_refactoring"}
|
|
11
|
+
ACTIONABLE_DOCS_LABELS = {
|
|
12
|
+
"useful_section_match",
|
|
13
|
+
"useful_docs_map_candidate",
|
|
14
|
+
"useful_doc_pair_review",
|
|
15
|
+
}
|
|
16
|
+
NON_ACTIONABLE_DOCS_LABELS = {"intentional_repetition", "not_actionable"}
|
|
17
|
+
DocsSectionSignature = tuple[tuple[str, int], tuple[str, int]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _section_signature(items: Iterable[tuple[str, int]]) -> DocsSectionSignature:
|
|
21
|
+
"""Return exactly two sorted section anchors as a stable signature."""
|
|
22
|
+
ordered = tuple(sorted(items))
|
|
23
|
+
if len(ordered) != 2:
|
|
24
|
+
raise ValueError("docs section signatures must contain exactly two sections")
|
|
25
|
+
return ordered[0], ordered[1]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def finding_signature(finding: dict, repo_root: str | Path) -> tuple[tuple[str, str], ...]:
|
|
29
|
+
"""Return a stable signature for a finding using repo-relative unit paths.
|
|
30
|
+
|
|
31
|
+
The signature is the sorted tuple of ``(relative_path, unit_name)`` pairs.
|
|
32
|
+
This makes labels resilient to different clone and artifact locations.
|
|
33
|
+
"""
|
|
34
|
+
root = Path(repo_root).resolve()
|
|
35
|
+
items: list[tuple[str, str]] = []
|
|
36
|
+
for unit in finding.get("units", []):
|
|
37
|
+
unit_path = Path(unit["file"])
|
|
38
|
+
if unit_path.is_absolute():
|
|
39
|
+
try:
|
|
40
|
+
rel_path = unit_path.resolve().relative_to(root).as_posix()
|
|
41
|
+
except ValueError:
|
|
42
|
+
rel_path = unit_path.as_posix()
|
|
43
|
+
else:
|
|
44
|
+
rel_path = unit_path.as_posix()
|
|
45
|
+
items.append((rel_path, unit["name"]))
|
|
46
|
+
return tuple(sorted(items))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_label_index(labels: list[dict]) -> dict[tuple[str, tuple[tuple[str, str], ...]], dict]:
|
|
50
|
+
"""Index benchmark labels by ``(repo, signature)``."""
|
|
51
|
+
index: dict[tuple[str, tuple[tuple[str, str], ...]], dict] = {}
|
|
52
|
+
for label in labels:
|
|
53
|
+
signature = tuple(sorted((item["path"], item["name"]) for item in label["units"]))
|
|
54
|
+
index[(label["repo"], signature)] = label
|
|
55
|
+
return index
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def score_labeled_findings(
|
|
59
|
+
repo_name: str,
|
|
60
|
+
findings: list[dict],
|
|
61
|
+
repo_root: str | Path,
|
|
62
|
+
labels: list[dict],
|
|
63
|
+
) -> dict:
|
|
64
|
+
"""Score findings against any stored public labels for that repo."""
|
|
65
|
+
label_index = build_label_index(labels)
|
|
66
|
+
matched: list[dict] = []
|
|
67
|
+
label_counts: Counter[str] = Counter()
|
|
68
|
+
|
|
69
|
+
for finding in findings:
|
|
70
|
+
signature = finding_signature(finding, repo_root)
|
|
71
|
+
label = label_index.get((repo_name, signature))
|
|
72
|
+
if label is None:
|
|
73
|
+
continue
|
|
74
|
+
matched.append(
|
|
75
|
+
{
|
|
76
|
+
"label": label["label"],
|
|
77
|
+
"units": list(signature),
|
|
78
|
+
"verdict": finding.get("verdict"),
|
|
79
|
+
"tier": finding.get("tier"),
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
label_counts[label["label"]] += 1
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
"matched_count": len(matched),
|
|
86
|
+
"matched_labels": dict(label_counts),
|
|
87
|
+
"matched_findings": matched,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _safe_divide(numerator: int, denominator: int) -> float | None:
|
|
92
|
+
if denominator == 0:
|
|
93
|
+
return None
|
|
94
|
+
return numerator / denominator
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _f1(precision: float | None, recall: float | None) -> float | None:
|
|
98
|
+
if precision is None or recall is None or precision + recall == 0:
|
|
99
|
+
return None
|
|
100
|
+
return 2 * precision * recall / (precision + recall)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _unit_matches(actual: dict, expected: dict) -> bool:
|
|
104
|
+
actual_name = str(actual.get("name", ""))
|
|
105
|
+
expected_name = str(expected.get("name", ""))
|
|
106
|
+
if actual_name != expected_name:
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
actual_path = Path(str(actual.get("file", ""))).as_posix()
|
|
110
|
+
expected_path = Path(str(expected.get("path", ""))).as_posix()
|
|
111
|
+
return actual_path == expected_path or actual_path.endswith(f"/{expected_path}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def finding_matches_label_units(finding: dict, expected_units: Iterable[dict]) -> bool:
|
|
115
|
+
"""Return whether a code finding contains exactly the expected labeled units.
|
|
116
|
+
|
|
117
|
+
Benchmark outputs may contain absolute clone paths while labels are stored
|
|
118
|
+
repo-relative. Matching by path suffix keeps quality scoring independent of
|
|
119
|
+
where the benchmark repos were cloned.
|
|
120
|
+
"""
|
|
121
|
+
actual_units = list(finding.get("units", []))
|
|
122
|
+
expected = list(expected_units)
|
|
123
|
+
if len(actual_units) != len(expected):
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
unmatched = actual_units[:]
|
|
127
|
+
for expected_unit in expected:
|
|
128
|
+
for i, actual in enumerate(unmatched):
|
|
129
|
+
if _unit_matches(actual, expected_unit):
|
|
130
|
+
unmatched.pop(i)
|
|
131
|
+
break
|
|
132
|
+
else:
|
|
133
|
+
return False
|
|
134
|
+
return not unmatched
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _quality_counts_to_metrics(counts: dict) -> dict:
|
|
138
|
+
tp = counts["true_positives"]
|
|
139
|
+
fp = counts["false_positives"]
|
|
140
|
+
fn = counts["false_negatives"]
|
|
141
|
+
precision = _safe_divide(tp, tp + fp)
|
|
142
|
+
recall = _safe_divide(tp, tp + fn)
|
|
143
|
+
return {
|
|
144
|
+
**counts,
|
|
145
|
+
"labeled_precision": precision,
|
|
146
|
+
"curated_recall": recall,
|
|
147
|
+
"f1": _f1(precision, recall),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _label_quality_kind(
|
|
152
|
+
label_name: str,
|
|
153
|
+
positive_labels: set[str],
|
|
154
|
+
negative_labels: set[str],
|
|
155
|
+
) -> str | None:
|
|
156
|
+
if label_name in positive_labels:
|
|
157
|
+
return "positive"
|
|
158
|
+
if label_name in negative_labels:
|
|
159
|
+
return "negative"
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _scored_labels(
|
|
164
|
+
labels: list[dict],
|
|
165
|
+
repo_name: str,
|
|
166
|
+
positive_labels: set[str],
|
|
167
|
+
negative_labels: set[str],
|
|
168
|
+
*,
|
|
169
|
+
track: str | None = None,
|
|
170
|
+
) -> list[dict]:
|
|
171
|
+
"""Return labels for a repo that participate in quality metrics."""
|
|
172
|
+
return [
|
|
173
|
+
label
|
|
174
|
+
for label in labels
|
|
175
|
+
if label.get("repo") == repo_name
|
|
176
|
+
and (track is None or label.get("track") == track)
|
|
177
|
+
and _label_quality_kind(str(label.get("label", "")), positive_labels, negative_labels)
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _add_rank_metrics(
|
|
182
|
+
metrics: dict,
|
|
183
|
+
true_positive_items: list[dict],
|
|
184
|
+
false_positive_items: list[dict],
|
|
185
|
+
positive_gold_count: int,
|
|
186
|
+
k_values: tuple[int, ...],
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Add precision@K and recall@K to a metrics dict."""
|
|
189
|
+
metrics["precision_at_k"] = {}
|
|
190
|
+
metrics["recall_at_k"] = {}
|
|
191
|
+
for k in k_values:
|
|
192
|
+
tp_at_k = sum(1 for item in true_positive_items if item["rank"] <= k)
|
|
193
|
+
fp_at_k = sum(1 for item in false_positive_items if item["rank"] <= k)
|
|
194
|
+
metrics["precision_at_k"][str(k)] = _safe_divide(tp_at_k, tp_at_k + fp_at_k)
|
|
195
|
+
metrics["recall_at_k"][str(k)] = _safe_divide(tp_at_k, positive_gold_count)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _quality_metrics_with_items(
|
|
199
|
+
scored_labels: list[dict],
|
|
200
|
+
positive_gold: list[dict],
|
|
201
|
+
surfaced_count: int,
|
|
202
|
+
true_positive_items: list[dict],
|
|
203
|
+
false_positive_items: list[dict],
|
|
204
|
+
false_negative_items: list[dict],
|
|
205
|
+
k_values: tuple[int, ...],
|
|
206
|
+
) -> dict:
|
|
207
|
+
"""Build benchmark quality metrics and attach item-level evidence."""
|
|
208
|
+
counts = {
|
|
209
|
+
"gold_positive_count": len(positive_gold),
|
|
210
|
+
"gold_negative_count": len(scored_labels) - len(positive_gold),
|
|
211
|
+
"surfaced_findings_count": surfaced_count,
|
|
212
|
+
"labeled_surfaced_count": len(true_positive_items) + len(false_positive_items),
|
|
213
|
+
"true_positives": len(true_positive_items),
|
|
214
|
+
"false_positives": len(false_positive_items),
|
|
215
|
+
"false_negatives": len(false_negative_items),
|
|
216
|
+
}
|
|
217
|
+
metrics = _quality_counts_to_metrics(counts)
|
|
218
|
+
_add_rank_metrics(
|
|
219
|
+
metrics,
|
|
220
|
+
true_positive_items,
|
|
221
|
+
false_positive_items,
|
|
222
|
+
len(positive_gold),
|
|
223
|
+
k_values,
|
|
224
|
+
)
|
|
225
|
+
metrics["true_positive_items"] = true_positive_items
|
|
226
|
+
metrics["false_positive_items"] = false_positive_items
|
|
227
|
+
metrics["false_negative_items"] = false_negative_items
|
|
228
|
+
return metrics
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def score_code_quality(
|
|
232
|
+
repo_name: str,
|
|
233
|
+
findings: list[dict],
|
|
234
|
+
labels: list[dict],
|
|
235
|
+
*,
|
|
236
|
+
positive_labels: set[str] | None = None,
|
|
237
|
+
negative_labels: set[str] | None = None,
|
|
238
|
+
k_values: tuple[int, ...] = (5, 10, 15),
|
|
239
|
+
) -> dict:
|
|
240
|
+
"""Score Code Review output against curated positive/negative labels.
|
|
241
|
+
|
|
242
|
+
Unlabeled surfaced findings are intentionally not counted as false
|
|
243
|
+
positives. The precision denominator is only labeled surfaced findings.
|
|
244
|
+
Recall is over curated positive labels for the repo.
|
|
245
|
+
"""
|
|
246
|
+
positive_labels = positive_labels or ACTIONABLE_CODE_LABELS
|
|
247
|
+
negative_labels = negative_labels or NON_ACTIONABLE_CODE_LABELS
|
|
248
|
+
scored_labels = _scored_labels(labels, repo_name, positive_labels, negative_labels)
|
|
249
|
+
positive_gold = [label for label in scored_labels if str(label.get("label")) in positive_labels]
|
|
250
|
+
|
|
251
|
+
matched_label_ids: set[int] = set()
|
|
252
|
+
true_positive_items: list[dict] = []
|
|
253
|
+
false_positive_items: list[dict] = []
|
|
254
|
+
|
|
255
|
+
for rank, finding in enumerate(findings, start=1):
|
|
256
|
+
for idx, label in enumerate(scored_labels):
|
|
257
|
+
if idx in matched_label_ids:
|
|
258
|
+
continue
|
|
259
|
+
if not finding_matches_label_units(finding, label.get("units", [])):
|
|
260
|
+
continue
|
|
261
|
+
matched_label_ids.add(idx)
|
|
262
|
+
item = {
|
|
263
|
+
"rank": rank,
|
|
264
|
+
"label": label["label"],
|
|
265
|
+
"units": label.get("units", []),
|
|
266
|
+
"verdict": finding.get("verdict"),
|
|
267
|
+
"tier": finding.get("tier"),
|
|
268
|
+
}
|
|
269
|
+
if label["label"] in positive_labels:
|
|
270
|
+
true_positive_items.append(item)
|
|
271
|
+
else:
|
|
272
|
+
false_positive_items.append(item)
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
false_negative_items = [
|
|
276
|
+
{
|
|
277
|
+
"label": label["label"],
|
|
278
|
+
"units": label.get("units", []),
|
|
279
|
+
}
|
|
280
|
+
for idx, label in enumerate(scored_labels)
|
|
281
|
+
if idx not in matched_label_ids and label["label"] in positive_labels
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
return _quality_metrics_with_items(
|
|
285
|
+
scored_labels,
|
|
286
|
+
positive_gold,
|
|
287
|
+
len(findings),
|
|
288
|
+
true_positive_items,
|
|
289
|
+
false_positive_items,
|
|
290
|
+
false_negative_items,
|
|
291
|
+
k_values,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def docs_section_signature(section_pair: dict) -> DocsSectionSignature:
|
|
296
|
+
"""Return a stable signature for a Section Match pair."""
|
|
297
|
+
chunk_a = section_pair.get("chunk_a", {})
|
|
298
|
+
chunk_b = section_pair.get("chunk_b", {})
|
|
299
|
+
return _section_signature(
|
|
300
|
+
(
|
|
301
|
+
(Path(str(chunk_a.get("file", ""))).as_posix(), int(chunk_a.get("line_start", 0))),
|
|
302
|
+
(Path(str(chunk_b.get("file", ""))).as_posix(), int(chunk_b.get("line_start", 0))),
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _docs_label_signature(label: dict) -> DocsSectionSignature:
|
|
308
|
+
sections = label.get("sections", [])
|
|
309
|
+
return _section_signature(
|
|
310
|
+
(Path(str(section["path"])).as_posix(), int(section["line_start"])) for section in sections
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def score_docs_section_quality(
|
|
315
|
+
repo_name: str,
|
|
316
|
+
section_pairs: list[dict],
|
|
317
|
+
labels: list[dict],
|
|
318
|
+
*,
|
|
319
|
+
positive_labels: set[str] | None = None,
|
|
320
|
+
negative_labels: set[str] | None = None,
|
|
321
|
+
k_values: tuple[int, ...] = (5, 10, 15),
|
|
322
|
+
) -> dict:
|
|
323
|
+
"""Score Section Match output against curated docs labels."""
|
|
324
|
+
positive_labels = positive_labels or ACTIONABLE_DOCS_LABELS
|
|
325
|
+
negative_labels = negative_labels or NON_ACTIONABLE_DOCS_LABELS
|
|
326
|
+
scored_labels = _scored_labels(
|
|
327
|
+
labels,
|
|
328
|
+
repo_name,
|
|
329
|
+
positive_labels,
|
|
330
|
+
negative_labels,
|
|
331
|
+
track="docs-section-match",
|
|
332
|
+
)
|
|
333
|
+
positive_gold = [label for label in scored_labels if label["label"] in positive_labels]
|
|
334
|
+
label_index = {_docs_label_signature(label): label for label in scored_labels}
|
|
335
|
+
|
|
336
|
+
matched_signatures: set[tuple[tuple[str, int], tuple[str, int]]] = set()
|
|
337
|
+
true_positive_items: list[dict] = []
|
|
338
|
+
false_positive_items: list[dict] = []
|
|
339
|
+
|
|
340
|
+
for rank, pair in enumerate(section_pairs, start=1):
|
|
341
|
+
signature = docs_section_signature(pair)
|
|
342
|
+
label = label_index.get(signature)
|
|
343
|
+
if label is None or signature in matched_signatures:
|
|
344
|
+
continue
|
|
345
|
+
matched_signatures.add(signature)
|
|
346
|
+
item = {
|
|
347
|
+
"rank": rank,
|
|
348
|
+
"label": label["label"],
|
|
349
|
+
"sections": label.get("sections", []),
|
|
350
|
+
"similarity": pair.get("embedding_similarity"),
|
|
351
|
+
}
|
|
352
|
+
if label["label"] in positive_labels:
|
|
353
|
+
true_positive_items.append(item)
|
|
354
|
+
else:
|
|
355
|
+
false_positive_items.append(item)
|
|
356
|
+
|
|
357
|
+
false_negative_items = [
|
|
358
|
+
{
|
|
359
|
+
"label": label["label"],
|
|
360
|
+
"sections": label.get("sections", []),
|
|
361
|
+
}
|
|
362
|
+
for label in positive_gold
|
|
363
|
+
if _docs_label_signature(label) not in matched_signatures
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
return _quality_metrics_with_items(
|
|
367
|
+
scored_labels,
|
|
368
|
+
positive_gold,
|
|
369
|
+
len(section_pairs),
|
|
370
|
+
true_positive_items,
|
|
371
|
+
false_positive_items,
|
|
372
|
+
false_negative_items,
|
|
373
|
+
k_values,
|
|
374
|
+
)
|
dryscope/cache.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""SQLite cache for LLM and embedding results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import sqlite3
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _make_key(content: str, model: str, prompt_version: str) -> str:
|
|
15
|
+
"""Create a cache key from content hash + model + prompt version."""
|
|
16
|
+
raw = f"{content}|{model}|{prompt_version}"
|
|
17
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CacheStats:
|
|
22
|
+
"""Cache statistics."""
|
|
23
|
+
|
|
24
|
+
entry_count: int
|
|
25
|
+
embedding_count: int
|
|
26
|
+
coding_count: int
|
|
27
|
+
db_size_bytes: int
|
|
28
|
+
hit_count: int
|
|
29
|
+
miss_count: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Cache:
|
|
33
|
+
"""SQLite-backed cache for embeddings and LLM responses."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, db_path: Path) -> None:
|
|
36
|
+
self.db_path = db_path
|
|
37
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
self.conn = sqlite3.connect(
|
|
39
|
+
str(self.db_path),
|
|
40
|
+
check_same_thread=False,
|
|
41
|
+
timeout=30.0,
|
|
42
|
+
isolation_level=None,
|
|
43
|
+
)
|
|
44
|
+
self._hits = 0
|
|
45
|
+
self._misses = 0
|
|
46
|
+
self._lock = threading.Lock()
|
|
47
|
+
self._configure_connection()
|
|
48
|
+
self._init_db()
|
|
49
|
+
|
|
50
|
+
def _configure_connection(self) -> None:
|
|
51
|
+
"""Tune SQLite for concurrent readers/writers across processes."""
|
|
52
|
+
self.conn.execute("PRAGMA busy_timeout=30000")
|
|
53
|
+
# Switching journal mode can race with another process opening the same
|
|
54
|
+
# cache. Retry briefly, then continue: an already-initialized cache can
|
|
55
|
+
# still be used even if this connection could not flip the mode itself.
|
|
56
|
+
attempts = 5
|
|
57
|
+
for attempt in range(attempts):
|
|
58
|
+
try:
|
|
59
|
+
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
60
|
+
break
|
|
61
|
+
except sqlite3.OperationalError as exc:
|
|
62
|
+
if "locked" not in str(exc).lower() or attempt == attempts - 1:
|
|
63
|
+
break
|
|
64
|
+
time.sleep(0.1 * (attempt + 1))
|
|
65
|
+
self.conn.execute("PRAGMA synchronous=NORMAL")
|
|
66
|
+
|
|
67
|
+
def _execute_write(self, sql: str, params: tuple[object, ...]) -> None:
|
|
68
|
+
"""Execute a write with simple retry for transient lock contention."""
|
|
69
|
+
attempts = 5
|
|
70
|
+
for attempt in range(attempts):
|
|
71
|
+
try:
|
|
72
|
+
self.conn.execute(sql, params)
|
|
73
|
+
return
|
|
74
|
+
except sqlite3.OperationalError as exc:
|
|
75
|
+
if "locked" not in str(exc).lower() or attempt == attempts - 1:
|
|
76
|
+
raise
|
|
77
|
+
time.sleep(0.1 * (attempt + 1))
|
|
78
|
+
|
|
79
|
+
def _init_db(self) -> None:
|
|
80
|
+
self.conn.execute("""
|
|
81
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
82
|
+
key TEXT PRIMARY KEY,
|
|
83
|
+
kind TEXT NOT NULL,
|
|
84
|
+
value TEXT NOT NULL,
|
|
85
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
86
|
+
)
|
|
87
|
+
""")
|
|
88
|
+
self.conn.commit()
|
|
89
|
+
|
|
90
|
+
def get_embedding(self, content: str, model: str) -> list[float] | None:
|
|
91
|
+
"""Retrieve a cached embedding vector."""
|
|
92
|
+
key = _make_key(content, model, "embedding_v1")
|
|
93
|
+
with self._lock:
|
|
94
|
+
row = self.conn.execute(
|
|
95
|
+
"SELECT value FROM cache WHERE key = ? AND kind = 'embedding'",
|
|
96
|
+
(key,),
|
|
97
|
+
).fetchone()
|
|
98
|
+
if row is not None:
|
|
99
|
+
self._hits += 1
|
|
100
|
+
return json.loads(row[0])
|
|
101
|
+
self._misses += 1
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def set_embedding(self, content: str, model: str, vector: list[float]) -> None:
|
|
105
|
+
"""Store an embedding vector in the cache."""
|
|
106
|
+
key = _make_key(content, model, "embedding_v1")
|
|
107
|
+
with self._lock:
|
|
108
|
+
self._execute_write(
|
|
109
|
+
"INSERT OR REPLACE INTO cache (key, kind, value) VALUES (?, 'embedding', ?)",
|
|
110
|
+
(key, json.dumps(vector)),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def get_coding(self, content: str, model: str, prompt_version: str) -> str | None:
|
|
114
|
+
"""Retrieve a cached LLM coding response."""
|
|
115
|
+
key = _make_key(content, model, prompt_version)
|
|
116
|
+
with self._lock:
|
|
117
|
+
row = self.conn.execute(
|
|
118
|
+
"SELECT value FROM cache WHERE key = ? AND kind = 'coding'",
|
|
119
|
+
(key,),
|
|
120
|
+
).fetchone()
|
|
121
|
+
if row is not None:
|
|
122
|
+
self._hits += 1
|
|
123
|
+
return row[0]
|
|
124
|
+
self._misses += 1
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
def set_coding(self, content: str, model: str, prompt_version: str, response: str) -> None:
|
|
128
|
+
"""Store an LLM coding response in the cache."""
|
|
129
|
+
key = _make_key(content, model, prompt_version)
|
|
130
|
+
with self._lock:
|
|
131
|
+
self._execute_write(
|
|
132
|
+
"INSERT OR REPLACE INTO cache (key, kind, value) VALUES (?, 'coding', ?)",
|
|
133
|
+
(key, response),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def commit(self) -> None:
|
|
137
|
+
"""Flush pending writes to disk."""
|
|
138
|
+
self.conn.commit()
|
|
139
|
+
|
|
140
|
+
def __enter__(self) -> Cache:
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
def __exit__(self, *args: object) -> None:
|
|
144
|
+
self.close()
|
|
145
|
+
|
|
146
|
+
def stats(self) -> CacheStats:
|
|
147
|
+
"""Get cache statistics."""
|
|
148
|
+
with self._lock:
|
|
149
|
+
total = self.conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
|
|
150
|
+
embeddings = self.conn.execute(
|
|
151
|
+
"SELECT COUNT(*) FROM cache WHERE kind = 'embedding'"
|
|
152
|
+
).fetchone()[0]
|
|
153
|
+
codings = self.conn.execute(
|
|
154
|
+
"SELECT COUNT(*) FROM cache WHERE kind = 'coding'"
|
|
155
|
+
).fetchone()[0]
|
|
156
|
+
db_size = self.db_path.stat().st_size if self.db_path.exists() else 0
|
|
157
|
+
return CacheStats(
|
|
158
|
+
entry_count=total,
|
|
159
|
+
embedding_count=embeddings,
|
|
160
|
+
coding_count=codings,
|
|
161
|
+
db_size_bytes=db_size,
|
|
162
|
+
hit_count=self._hits,
|
|
163
|
+
miss_count=self._misses,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def clear(self) -> None:
|
|
167
|
+
"""Delete all cache entries."""
|
|
168
|
+
with self._lock:
|
|
169
|
+
self.conn.execute("DELETE FROM cache")
|
|
170
|
+
self.conn.commit()
|
|
171
|
+
|
|
172
|
+
def close(self) -> None:
|
|
173
|
+
"""Commit pending writes and close the database connection."""
|
|
174
|
+
self.conn.commit()
|
|
175
|
+
self.conn.close()
|