backspec 0.0.1__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.
- backspec/__init__.py +16 -0
- backspec/host/__init__.py +0 -0
- backspec/host/adapters/__init__.py +0 -0
- backspec/host/adapters/cache.py +103 -0
- backspec/host/adapters/cache_admin.py +64 -0
- backspec/host/adapters/capabilities_file.py +39 -0
- backspec/host/adapters/claude_code.py +77 -0
- backspec/host/adapters/codex.py +67 -0
- backspec/host/adapters/fake.py +39 -0
- backspec/host/adapters/filesystem.py +50 -0
- backspec/host/adapters/review_yaml.py +33 -0
- backspec/host/adapters/treesitter.py +130 -0
- backspec/host/cli.py +313 -0
- backspec/host/composition.py +211 -0
- backspec/kernel/__init__.py +0 -0
- backspec/kernel/llm.py +64 -0
- backspec/kernel/prompts.py +10 -0
- backspec/kernel/templates.py +12 -0
- backspec/kernel/text.py +23 -0
- backspec/kernel/usage.py +50 -0
- backspec/modules/__init__.py +0 -0
- backspec/modules/capabilities/__init__.py +0 -0
- backspec/modules/capabilities/domain.py +108 -0
- backspec/modules/capabilities/facade.py +48 -0
- backspec/modules/capabilities/ports.py +21 -0
- backspec/modules/capabilities/use_cases.py +63 -0
- backspec/modules/discovery/__init__.py +0 -0
- backspec/modules/discovery/domain.py +87 -0
- backspec/modules/discovery/facade.py +16 -0
- backspec/modules/discovery/ports.py +14 -0
- backspec/modules/discovery/use_cases.py +19 -0
- backspec/modules/evidence/__init__.py +0 -0
- backspec/modules/evidence/domain.py +276 -0
- backspec/modules/evidence/facade.py +24 -0
- backspec/modules/evidence/ports.py +12 -0
- backspec/modules/evidence/use_cases.py +128 -0
- backspec/modules/extraction/__init__.py +0 -0
- backspec/modules/extraction/facade.py +6 -0
- backspec/modules/extraction/use_cases.py +228 -0
- backspec/modules/lint/__init__.py +0 -0
- backspec/modules/lint/domain.py +152 -0
- backspec/modules/lint/facade.py +7 -0
- backspec/modules/lint/use_cases.py +16 -0
- backspec/modules/report/__init__.py +0 -0
- backspec/modules/report/domain.py +128 -0
- backspec/modules/report/dto.py +92 -0
- backspec/modules/report/facade.py +11 -0
- backspec/modules/report/renderers.py +220 -0
- backspec/modules/report/use_cases.py +127 -0
- backspec/modules/requirements/__init__.py +0 -0
- backspec/modules/requirements/domain.py +272 -0
- backspec/modules/requirements/facade.py +26 -0
- backspec/modules/requirements/use_cases.py +135 -0
- backspec/modules/review/__init__.py +0 -0
- backspec/modules/review/domain.py +129 -0
- backspec/modules/review/facade.py +32 -0
- backspec/modules/review/ports.py +15 -0
- backspec/modules/review/use_cases.py +89 -0
- backspec/modules/staleness/__init__.py +0 -0
- backspec/modules/staleness/domain.py +75 -0
- backspec/modules/staleness/facade.py +17 -0
- backspec/modules/staleness/ports.py +7 -0
- backspec/modules/staleness/use_cases.py +39 -0
- backspec/modules/triage/__init__.py +0 -0
- backspec/modules/triage/domain.py +144 -0
- backspec/modules/triage/facade.py +24 -0
- backspec/modules/triage/use_cases.py +79 -0
- backspec/prompts/assess.md +32 -0
- backspec/prompts/assign.md +18 -0
- backspec/prompts/consolidate.md +52 -0
- backspec/prompts/observe.md +52 -0
- backspec/prompts/orient.md +33 -0
- backspec/prompts/synthesise.md +97 -0
- backspec/prompts/triage.md +34 -0
- backspec/py.typed +0 -0
- backspec/templates/libre-baskerville-400.woff2 +0 -0
- backspec/templates/libre-baskerville-700.woff2 +0 -0
- backspec/templates/libre-baskerville-OFL.txt +93 -0
- backspec/templates/report.html +336 -0
- backspec-0.0.1.dist-info/METADATA +430 -0
- backspec-0.0.1.dist-info/RECORD +85 -0
- backspec-0.0.1.dist-info/WHEEL +4 -0
- backspec-0.0.1.dist-info/entry_points.txt +2 -0
- backspec-0.0.1.dist-info/licenses/LICENSE +21 -0
- backspec-0.0.1.dist-info/licenses/src/backspec/templates/libre-baskerville-OFL.txt +93 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Observations: single facts a file establishes, each tied to lines that are checked in code.
|
|
2
|
+
|
|
3
|
+
An observation is evidence. It is not a requirement. A citation is accepted only when its quote
|
|
4
|
+
is really in the file: inside the cited lines (exact), or in exactly one other place, to which
|
|
5
|
+
the range is then moved (repaired). Anything else is rejected and recorded.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import difflib
|
|
10
|
+
import hashlib
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
from backspec.kernel.text import numbered, short_hash, squash
|
|
18
|
+
|
|
19
|
+
Kind = Literal["entry_point", "permission", "validation", "state_change", "rule", "display",
|
|
20
|
+
"error_path", "test_case", "doc_claim", "other"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Symbol:
|
|
25
|
+
"""A named definition in a file. Lines are 1-based and inclusive. `name` is qualified by the
|
|
26
|
+
symbols around it (`Class.method`)."""
|
|
27
|
+
name: str
|
|
28
|
+
kind: Literal["function", "method", "class", "other"]
|
|
29
|
+
first_line: int
|
|
30
|
+
last_line: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Chunk:
|
|
35
|
+
"""A run of lines from one file, with the real number of its first line."""
|
|
36
|
+
path: str
|
|
37
|
+
first: int
|
|
38
|
+
lines: tuple[str, ...]
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def last(self) -> int:
|
|
42
|
+
return self.first + len(self.lines) - 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RawObservation(BaseModel):
|
|
46
|
+
"""An observation as the model gave it, before any check."""
|
|
47
|
+
path: str = ""
|
|
48
|
+
lines: list[int] = Field(default_factory=list)
|
|
49
|
+
quote: str = ""
|
|
50
|
+
kind: str = "other"
|
|
51
|
+
statement: str = ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class NothingFound(BaseModel):
|
|
55
|
+
path: str
|
|
56
|
+
reason: str = ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ObserveReply(BaseModel):
|
|
60
|
+
observations: list[RawObservation] = Field(default_factory=list)
|
|
61
|
+
none: list[NothingFound] = Field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Observation(BaseModel):
|
|
65
|
+
id: str
|
|
66
|
+
statement: str
|
|
67
|
+
kind: str
|
|
68
|
+
path: str
|
|
69
|
+
lines: tuple[int, int]
|
|
70
|
+
quote: str
|
|
71
|
+
source_hash: str
|
|
72
|
+
verified: Literal["exact", "repaired"]
|
|
73
|
+
symbol: str | None = None # innermost symbol holding the whole range; set in code, never by a model
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Earlier:
|
|
78
|
+
"""What an earlier run established about one file: the file as it was read then, and the
|
|
79
|
+
observations that verified. An unchanged file need not be read again."""
|
|
80
|
+
source_hash: str
|
|
81
|
+
observations: tuple["Observation", ...]
|
|
82
|
+
nothing_reason: str | None = None
|
|
83
|
+
line_digest: str = "" # the file line by line, to find what changed since; empty if not recorded
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Rejected(BaseModel):
|
|
87
|
+
path: str
|
|
88
|
+
lines: list[int]
|
|
89
|
+
quote: str
|
|
90
|
+
statement: str
|
|
91
|
+
reason: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def split_file(path: str, lines: list[str], limit: int, symbols: list[Symbol] | None = None) -> list[Chunk]:
|
|
95
|
+
"""A file as chunks of at most `limit` lines. With `symbols`, a cut falls between top-level
|
|
96
|
+
symbols, never inside one, whenever that fits within the limit. Otherwise (and for a single
|
|
97
|
+
symbol larger than the limit) it is at a blank line before top-level code where one is near."""
|
|
98
|
+
cuts = _symbol_cuts(lines, symbols) if symbols else []
|
|
99
|
+
chunks, start = [], 0
|
|
100
|
+
while start < len(lines):
|
|
101
|
+
end = min(start + limit, len(lines))
|
|
102
|
+
if end < len(lines):
|
|
103
|
+
fits = [cut for cut in cuts if start < cut <= end]
|
|
104
|
+
if fits:
|
|
105
|
+
end = max(fits)
|
|
106
|
+
else:
|
|
107
|
+
end = _text_cut(lines, start, end, limit)
|
|
108
|
+
chunks.append(Chunk(path, start + 1, tuple(lines[start:end])))
|
|
109
|
+
start = end
|
|
110
|
+
return chunks
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _text_cut(lines: list[str], start: int, end: int, limit: int) -> int:
|
|
114
|
+
for cut in range(end, max(start + limit // 2, end - 150), -1):
|
|
115
|
+
if not lines[cut - 1].strip() and not lines[cut].startswith((" ", "\t")):
|
|
116
|
+
return cut
|
|
117
|
+
return end
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _symbol_cuts(lines: list[str], symbols: list[Symbol]) -> list[int]:
|
|
121
|
+
"""Line numbers after which a chunk may end: just after a top-level symbol, or just before one
|
|
122
|
+
(taking the comment lines directly above it along with it)."""
|
|
123
|
+
top: list[Symbol] = []
|
|
124
|
+
for symbol in sorted(symbols, key=lambda s: (s.first_line, -s.last_line)):
|
|
125
|
+
if not top or symbol.first_line > top[-1].last_line:
|
|
126
|
+
top.append(symbol)
|
|
127
|
+
cuts: set[int] = set()
|
|
128
|
+
previous_end = 0
|
|
129
|
+
for symbol in top:
|
|
130
|
+
cut = symbol.first_line - 1
|
|
131
|
+
while cut > previous_end and cut <= len(lines) and lines[cut - 1].strip():
|
|
132
|
+
cut -= 1
|
|
133
|
+
cuts.add(cut)
|
|
134
|
+
cuts.add(symbol.last_line)
|
|
135
|
+
previous_end = symbol.last_line
|
|
136
|
+
return sorted(c for c in cuts if 0 < c < len(lines))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def enclosing_symbol(symbols: list[Symbol] | None, first: int, last: int) -> str | None:
|
|
140
|
+
"""The qualified name of the innermost symbol that contains the whole range, or None."""
|
|
141
|
+
best: Symbol | None = None
|
|
142
|
+
for symbol in symbols or []:
|
|
143
|
+
if symbol.first_line <= first and last <= symbol.last_line:
|
|
144
|
+
if best is None or (symbol.last_line - symbol.first_line, -symbol.name.count(".")) < \
|
|
145
|
+
(best.last_line - best.first_line, -best.name.count(".")):
|
|
146
|
+
best = symbol
|
|
147
|
+
return best.name if best else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def pack(chunks: list[Chunk], limit: int) -> list[list[Chunk]]:
|
|
151
|
+
"""Chunks grouped into batches of at most `limit` lines, in order."""
|
|
152
|
+
batches: list[list[Chunk]] = []
|
|
153
|
+
current: list[Chunk] = []
|
|
154
|
+
size = 0
|
|
155
|
+
for chunk in chunks:
|
|
156
|
+
if current and size + len(chunk.lines) > limit:
|
|
157
|
+
batches.append(current)
|
|
158
|
+
current, size = [], 0
|
|
159
|
+
current.append(chunk)
|
|
160
|
+
size += len(chunk.lines)
|
|
161
|
+
if current:
|
|
162
|
+
batches.append(current)
|
|
163
|
+
return batches
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def render(batch: list[Chunk]) -> str:
|
|
167
|
+
"""A batch as the model sees it: a header per file, then every line with its real number."""
|
|
168
|
+
out: list[str] = []
|
|
169
|
+
for chunk in batch:
|
|
170
|
+
out.append(f"=== FILE: {chunk.path} (lines {chunk.first}-{chunk.last}) ===")
|
|
171
|
+
out += numbered(list(chunk.lines), chunk.first)
|
|
172
|
+
out.append("")
|
|
173
|
+
return "\n".join(out)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
DIGEST_WIDTH = 6 # characters of digest kept for each line
|
|
177
|
+
CONTEXT_AROUND_CHANGE = 30
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def observation_id(path: str, cited_lines: list[str], statement: str) -> str:
|
|
181
|
+
"""An id that depends on the cited text, not on where it sits. Moving the code keeps the id;
|
|
182
|
+
changing the cited lines changes it."""
|
|
183
|
+
return "OBS-" + short_hash(path, squash("\n".join(cited_lines)), statement.strip())
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def line_digest(lines: list[str]) -> str:
|
|
187
|
+
"""The file as one short digest per line, enough to tell later which lines changed."""
|
|
188
|
+
return "".join(hashlib.sha1(squash(line).encode()).hexdigest()[:DIGEST_WIDTH] for line in lines)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True)
|
|
192
|
+
class Change:
|
|
193
|
+
"""How a file differs from the one an earlier run read. `kept` are runs of lines that are the
|
|
194
|
+
same, as (old first line, new first line, length). `changed` are the new or altered line
|
|
195
|
+
ranges in the file as it is now."""
|
|
196
|
+
kept: tuple[tuple[int, int, int], ...]
|
|
197
|
+
changed: tuple[tuple[int, int], ...]
|
|
198
|
+
|
|
199
|
+
def moved(self, first: int, last: int) -> tuple[int, int] | None:
|
|
200
|
+
"""Where an old line range is now, if every line of it is unchanged."""
|
|
201
|
+
for old, new, length in self.kept:
|
|
202
|
+
if old <= first and last < old + length:
|
|
203
|
+
return first - old + new, last - old + new
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
def touches(self, first: int, last: int) -> bool:
|
|
207
|
+
return any(a <= last and first <= b for a, b in self.changed)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def compare(old_digest: str, new_lines: list[str]) -> Change:
|
|
211
|
+
old = [old_digest[i:i + DIGEST_WIDTH] for i in range(0, len(old_digest), DIGEST_WIDTH)]
|
|
212
|
+
digest = line_digest(new_lines)
|
|
213
|
+
new = [digest[i:i + DIGEST_WIDTH] for i in range(0, len(digest), DIGEST_WIDTH)]
|
|
214
|
+
kept, changed = [], []
|
|
215
|
+
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(a=old, b=new, autojunk=False).get_opcodes():
|
|
216
|
+
if tag == "equal":
|
|
217
|
+
kept.append((i1 + 1, j1 + 1, i2 - i1))
|
|
218
|
+
elif j2 > j1:
|
|
219
|
+
changed.append((j1 + 1, j2))
|
|
220
|
+
return Change(tuple(kept), tuple(changed))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def windows(changed: tuple[tuple[int, int], ...], total: int, context: int = CONTEXT_AROUND_CHANGE) -> list[tuple[int, int]]:
|
|
224
|
+
"""The line ranges to show a model: each change with context either side, overlaps merged."""
|
|
225
|
+
out: list[list[int]] = []
|
|
226
|
+
for first, last in changed:
|
|
227
|
+
a, b = max(1, first - context), min(total, last + context)
|
|
228
|
+
if out and a <= out[-1][1] + 1:
|
|
229
|
+
out[-1][1] = max(out[-1][1], b)
|
|
230
|
+
else:
|
|
231
|
+
out.append([a, b])
|
|
232
|
+
return [(a, b) for a, b in out]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def carried(observation: "Observation", change: Change, source_hash: str) -> "Observation | None":
|
|
236
|
+
"""An earlier observation as it stands in the changed file, or None if its lines changed."""
|
|
237
|
+
where = change.moved(*observation.lines)
|
|
238
|
+
if where is None:
|
|
239
|
+
return None
|
|
240
|
+
return observation.model_copy(update={"lines": where, "source_hash": source_hash})
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _clean(quote: str) -> str:
|
|
244
|
+
"""A quote without the ` 123| ` prefix a model sometimes copies along with the line."""
|
|
245
|
+
return squash(re.sub(r"^\s*\d+\|\s?", "", quote))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def verify(raw: RawObservation, shown: set[str], file_lines: dict[str, list[str]],
|
|
249
|
+
source_hashes: dict[str, str]) -> Observation | Rejected:
|
|
250
|
+
"""Check one observation against the files the model was shown."""
|
|
251
|
+
def reject(reason: str) -> Rejected:
|
|
252
|
+
return Rejected(path=raw.path, lines=raw.lines, quote=raw.quote, statement=raw.statement, reason=reason)
|
|
253
|
+
|
|
254
|
+
if raw.path not in shown:
|
|
255
|
+
return reject("path was not shown to the model")
|
|
256
|
+
if not raw.statement.strip():
|
|
257
|
+
return reject("no statement")
|
|
258
|
+
quote = _clean(raw.quote)
|
|
259
|
+
if len(quote) < 4:
|
|
260
|
+
return reject("no usable quote")
|
|
261
|
+
lines = file_lines[raw.path]
|
|
262
|
+
span = raw.lines if len(raw.lines) == 2 and 1 <= raw.lines[0] <= raw.lines[1] <= len(lines) else None
|
|
263
|
+
verdict: Literal["exact", "repaired"]
|
|
264
|
+
if span and quote in squash("\n".join(lines[span[0] - 1:span[1]])):
|
|
265
|
+
verdict, where = "exact", (span[0], span[1])
|
|
266
|
+
else:
|
|
267
|
+
hits = [number for number, line in enumerate(lines, 1) if quote in squash(line)]
|
|
268
|
+
if len(hits) != 1:
|
|
269
|
+
return reject("quote not found" if not hits else "quote found more than once")
|
|
270
|
+
width = min(span[1] - span[0], 30) if span else 0
|
|
271
|
+
first = max(1, hits[0] - width // 2)
|
|
272
|
+
verdict, where = "repaired", (first, min(len(lines), first + width))
|
|
273
|
+
return Observation(
|
|
274
|
+
id=observation_id(raw.path, lines[where[0] - 1:where[1]], raw.statement),
|
|
275
|
+
statement=raw.statement.strip(), kind=raw.kind, path=raw.path, lines=where,
|
|
276
|
+
quote=_clean(raw.quote), source_hash=source_hashes[raw.path], verified=verdict)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Evidence, as the rest of backspec sees it."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from backspec.kernel.llm import LLMPort
|
|
5
|
+
from backspec.modules.discovery.facade import SourceFile
|
|
6
|
+
from backspec.modules.evidence.domain import Earlier, NothingFound, Observation, Rejected, Symbol, line_digest
|
|
7
|
+
from backspec.modules.evidence.ports import SyntaxPort
|
|
8
|
+
from backspec.modules.evidence.use_cases import ObservationSet, fingerprint, observe
|
|
9
|
+
|
|
10
|
+
__all__ = ["Earlier", "EvidenceFacade", "NothingFound", "Observation", "ObservationSet", "Rejected", "Symbol", "SyntaxPort", "line_digest"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EvidenceFacade:
|
|
14
|
+
def __init__(self, llm: LLMPort, workers: int = 4, syntax: SyntaxPort | None = None):
|
|
15
|
+
self._llm = llm
|
|
16
|
+
self._syntax = syntax
|
|
17
|
+
self._workers = workers
|
|
18
|
+
|
|
19
|
+
def observe(self, files: list[SourceFile], earlier: dict[str, Earlier] | None = None) -> ObservationSet:
|
|
20
|
+
return observe(files, self._llm, self._workers, earlier=earlier, syntax=self._syntax)
|
|
21
|
+
|
|
22
|
+
def fingerprint(self) -> str:
|
|
23
|
+
"""Identifies the way files are observed; earlier observations made another way are not reused."""
|
|
24
|
+
return fingerprint(syntax=self._syntax is not None)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""What evidence needs from outside."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from backspec.modules.evidence.domain import Symbol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SyntaxPort(Protocol):
|
|
10
|
+
def symbols(self, path: str, text: str) -> list[Symbol] | None:
|
|
11
|
+
"""The definitions in a file, nested ones included, or None when the file cannot be parsed."""
|
|
12
|
+
...
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Observe: read every file in numbered batches and keep only what verifies.
|
|
2
|
+
|
|
3
|
+
A model does not give the same observations twice for the same file. So a file that has not
|
|
4
|
+
changed since an earlier run keeps that run's observations, and only new or changed files are
|
|
5
|
+
read. Everything built on observations, requirement ids included, is then stable between runs.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from backspec.kernel import prompts
|
|
13
|
+
from backspec.kernel.llm import LLMError, LLMPort, LLMRequest, ask
|
|
14
|
+
from backspec.modules.discovery.facade import SourceFile
|
|
15
|
+
from backspec.kernel.text import short_hash
|
|
16
|
+
from backspec.modules.evidence.domain import (Chunk, Earlier, NothingFound, Observation, ObserveReply, Rejected,
|
|
17
|
+
Symbol, carried, compare, enclosing_symbol, pack, render, split_file,
|
|
18
|
+
verify, windows)
|
|
19
|
+
from backspec.modules.evidence.ports import SyntaxPort
|
|
20
|
+
|
|
21
|
+
BATCH_LINES = 1300
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ObservationSet:
|
|
26
|
+
observations: list[Observation] = field(default_factory=list)
|
|
27
|
+
rejected: list[Rejected] = field(default_factory=list)
|
|
28
|
+
nothing: list[NothingFound] = field(default_factory=list)
|
|
29
|
+
failed: dict[str, str] = field(default_factory=dict) # path -> why its batch failed
|
|
30
|
+
batches: int = 0
|
|
31
|
+
reused_files: int = 0 # unchanged since the earlier run, so not read again
|
|
32
|
+
partly_read_files: int = 0 # changed since the earlier run: only the changed parts were read
|
|
33
|
+
outside_change: int = 0 # observations a model made of unchanged lines while reading a change; not kept
|
|
34
|
+
analysis: dict[str, str] = field(default_factory=dict) # path -> "syntax" or "text": how the file was analysed
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fingerprint(batch_lines: int = BATCH_LINES, syntax: bool = False) -> str:
|
|
38
|
+
"""Changes whenever the way files are observed does, syntax support on or off included.
|
|
39
|
+
Earlier observations made another way are not reused."""
|
|
40
|
+
parts = [prompts.load("observe"), batch_lines, "ids-by-cited-text"]
|
|
41
|
+
return short_hash(*parts, "syntax") if syntax else short_hash(*parts)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _symbols(syntax: SyntaxPort | None, file: SourceFile) -> list[Symbol] | None:
|
|
45
|
+
"""The symbols of a file, or None: no parser, no grammar, or a parser that failed."""
|
|
46
|
+
if syntax is None:
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
return syntax.symbols(file.path, file.text)
|
|
50
|
+
except Exception:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def observe(files: list[SourceFile], llm: LLMPort, workers: int = 4, batch_lines: int = BATCH_LINES,
|
|
55
|
+
earlier: dict[str, Earlier] | None = None, syntax: SyntaxPort | None = None) -> ObservationSet:
|
|
56
|
+
symbols = {f.path: _symbols(syntax, f) for f in files}
|
|
57
|
+
file_lines = {f.path: f.lines for f in files}
|
|
58
|
+
hashes = {f.path: f.source_hash for f in files}
|
|
59
|
+
result = ObservationSet(analysis={path: "text" if found is None else "syntax" for path, found in symbols.items()})
|
|
60
|
+
seen: set[str] = set()
|
|
61
|
+
to_read = []
|
|
62
|
+
partial: list[Chunk] = []
|
|
63
|
+
changes = {}
|
|
64
|
+
for f in files:
|
|
65
|
+
known = (earlier or {}).get(f.path)
|
|
66
|
+
if known is None:
|
|
67
|
+
to_read.append(f)
|
|
68
|
+
continue
|
|
69
|
+
if known.source_hash != f.source_hash:
|
|
70
|
+
if not known.line_digest:
|
|
71
|
+
to_read.append(f)
|
|
72
|
+
continue
|
|
73
|
+
# Changed since the earlier run. Observations whose lines are untouched stay, moved to
|
|
74
|
+
# where those lines are now; only the changed parts, with context, are read again.
|
|
75
|
+
change = compare(known.line_digest, f.lines)
|
|
76
|
+
result.partly_read_files += 1
|
|
77
|
+
for observation in known.observations:
|
|
78
|
+
still = carried(observation, change, f.source_hash)
|
|
79
|
+
if still is not None and still.id not in seen:
|
|
80
|
+
seen.add(still.id)
|
|
81
|
+
result.observations.append(still)
|
|
82
|
+
for first, last in windows(change.changed, len(f.lines)):
|
|
83
|
+
partial.append(Chunk(f.path, first, tuple(f.lines[first - 1:last])))
|
|
84
|
+
changes[f.path] = change
|
|
85
|
+
continue
|
|
86
|
+
result.reused_files += 1
|
|
87
|
+
for observation in known.observations:
|
|
88
|
+
if observation.source_hash == f.source_hash and observation.id not in seen:
|
|
89
|
+
seen.add(observation.id)
|
|
90
|
+
result.observations.append(observation)
|
|
91
|
+
if known.nothing_reason is not None and not known.observations:
|
|
92
|
+
result.nothing.append(NothingFound(path=f.path, reason=known.nothing_reason))
|
|
93
|
+
chunks = [chunk for f in to_read for chunk in split_file(f.path, f.lines, batch_lines, symbols[f.path])] + partial
|
|
94
|
+
batches = pack(chunks, batch_lines)
|
|
95
|
+
result.batches = len(batches)
|
|
96
|
+
instructions = prompts.load("observe")
|
|
97
|
+
|
|
98
|
+
def run(batch: list[Chunk]) -> ObserveReply | LLMError:
|
|
99
|
+
try:
|
|
100
|
+
return ask(llm, LLMRequest("observe", f"{instructions}\n\n# The batch\n\n{render(batch)}", "fast"), ObserveReply)
|
|
101
|
+
except LLMError as error:
|
|
102
|
+
return error
|
|
103
|
+
|
|
104
|
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
|
105
|
+
replies = list(pool.map(run, batches))
|
|
106
|
+
for batch, reply in zip(batches, replies):
|
|
107
|
+
shown = {chunk.path for chunk in batch}
|
|
108
|
+
if isinstance(reply, LLMError):
|
|
109
|
+
for path in shown:
|
|
110
|
+
result.failed[path] = str(reply)[:300]
|
|
111
|
+
continue
|
|
112
|
+
result.nothing += [entry for entry in reply.none if entry.path in shown]
|
|
113
|
+
for raw in reply.observations:
|
|
114
|
+
checked = verify(raw, shown, file_lines, hashes)
|
|
115
|
+
if isinstance(checked, Rejected):
|
|
116
|
+
result.rejected.append(checked)
|
|
117
|
+
elif checked.path in changes and not changes[checked.path].touches(*checked.lines):
|
|
118
|
+
# Read only as context for a change. What the earlier run said of these lines stands.
|
|
119
|
+
result.outside_change += 1
|
|
120
|
+
elif checked.id not in seen:
|
|
121
|
+
seen.add(checked.id)
|
|
122
|
+
result.observations.append(checked)
|
|
123
|
+
# Where an observation sits is worked out here, from the file as it is now, for new, carried and
|
|
124
|
+
# reused observations alike: code may have moved between symbols since an earlier run.
|
|
125
|
+
result.observations = [
|
|
126
|
+
o.model_copy(update={"symbol": symbol}) if (symbol := enclosing_symbol(symbols.get(o.path), *o.lines)) != o.symbol else o
|
|
127
|
+
for o in result.observations]
|
|
128
|
+
return result
|
|
File without changes
|