SourceIndex 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.
- sourceindex/__init__.py +30 -0
- sourceindex/build/__init__.py +592 -0
- sourceindex/build/indexer.py +403 -0
- sourceindex/build/linerange/__init__.py +24 -0
- sourceindex/build/linerange/python_ast.py +155 -0
- sourceindex/build/linerange/treesitter.py +397 -0
- sourceindex/build/prompts.py +76 -0
- sourceindex/build/state.py +261 -0
- sourceindex/build/walker.py +94 -0
- sourceindex/claudecode/__init__.py +0 -0
- sourceindex/claudecode/savings.py +325 -0
- sourceindex/claudecode/savings_summary.py +88 -0
- sourceindex/claudecode/statusline.py +116 -0
- sourceindex/cli/__init__.py +304 -0
- sourceindex/cli/__main__.py +10 -0
- sourceindex/cli/api_key.py +171 -0
- sourceindex/cli/commands.py +678 -0
- sourceindex/cli/install.py +378 -0
- sourceindex/daemon/__init__.py +83 -0
- sourceindex/daemon/client.py +141 -0
- sourceindex/daemon/crypto.py +48 -0
- sourceindex/daemon/keyring_store.py +129 -0
- sourceindex/daemon/lifecycle.py +237 -0
- sourceindex/daemon/protocol.py +134 -0
- sourceindex/daemon/server.py +389 -0
- sourceindex/daemon/store.py +426 -0
- sourceindex/lib/__init__.py +0 -0
- sourceindex/lib/backend.py +240 -0
- sourceindex/lib/cost.py +96 -0
- sourceindex/lib/env.py +30 -0
- sourceindex/lib/git.py +33 -0
- sourceindex/lib/languages.py +406 -0
- sourceindex/lib/llm.py +298 -0
- sourceindex/lib/log.py +228 -0
- sourceindex/lib/registry.py +75 -0
- sourceindex/lib/timing.py +10 -0
- sourceindex/search/__init__.py +251 -0
- sourceindex/search/experiments.py +276 -0
- sourceindex/search/imports.py +155 -0
- sourceindex/search/passes.py +51 -0
- sourceindex/search/prompts.py +379 -0
- sourceindex/search/roadmap.py +265 -0
- sourceindex/server.py +127 -0
- sourceindex-0.1.0.dist-info/METADATA +73 -0
- sourceindex-0.1.0.dist-info/RECORD +47 -0
- sourceindex-0.1.0.dist-info/WHEEL +4 -0
- sourceindex-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""LLM dispatch, response parsing, batch concurrency, and progress UI.
|
|
2
|
+
|
|
3
|
+
The indexer turns ``(content, relative_path, language)`` triples into
|
|
4
|
+
``(oneliner, functions_text, usage)`` triples. Two delivery shapes:
|
|
5
|
+
|
|
6
|
+
- ``index_file`` — single-file, used by sequential update fallback.
|
|
7
|
+
- ``submit_and_wait_batch`` + ``index_by_hash`` — concurrent batch dispatch
|
|
8
|
+
via a ThreadPoolExecutor over ``call_llm``. Used for cold builds and
|
|
9
|
+
multi-file updates.
|
|
10
|
+
|
|
11
|
+
Progress UI (``_format_eta``, ``_render_progress_bar``, ``_emit_progress``)
|
|
12
|
+
and fd-capacity management (``_ensure_fd_capacity``) live here too —
|
|
13
|
+
they're only invoked from the batch path and don't deserve their own files.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
19
|
+
from typing import Callable
|
|
20
|
+
|
|
21
|
+
import litellm
|
|
22
|
+
|
|
23
|
+
from .. import REALTIME_MAX_WORKERS
|
|
24
|
+
from ..lib.languages import Language
|
|
25
|
+
from ..lib.llm import LLMUsage, call_llm
|
|
26
|
+
from ..lib.log import get_logger
|
|
27
|
+
from .linerange import fix_functions_for_language
|
|
28
|
+
from .prompts import prompt_for
|
|
29
|
+
|
|
30
|
+
_log = get_logger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
REALTIME_MAX_RETRIES = 5
|
|
34
|
+
# update_index falls back to sequential index_file() below this miss count —
|
|
35
|
+
# the threadpool + per-request setup cost isn't worth amortizing for a single
|
|
36
|
+
# file, but pays for itself at 2+.
|
|
37
|
+
BATCH_MIN_FILES = 2
|
|
38
|
+
RETRY_DELAY = 1.0
|
|
39
|
+
# Best price/latency/quality balance found across DS-V4-Flash, Haiku 4.5,
|
|
40
|
+
# Mistral-Small, Gemini-Flash-Lite, gpt-5-nano, gpt-5.4-nano (Opus-judged
|
|
41
|
+
# description quality + AST-grounded recall + line-range accuracy).
|
|
42
|
+
# Reached via SOURCEINDEX_API_KEY: an sk-si bearer routes through the hosted
|
|
43
|
+
# backend, an sk-or-/sk-ant-/etc. provider key auto-opts-out and hits the
|
|
44
|
+
# provider directly. SOURCEINDEX_BACKEND_URL pins/disables the routing.
|
|
45
|
+
DEFAULT_MODEL = "openrouter/openai/gpt-5.4-nano"
|
|
46
|
+
BATCH_POLL_INTERVAL = 30
|
|
47
|
+
BATCH_MAX_WAIT = 3600
|
|
48
|
+
BATCH_MAX_TOKENS = 16384
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Response parsing ───────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_response(response: str) -> tuple[str, str]:
|
|
55
|
+
"""Parse merged LLM response into (oneliner, functions_text)."""
|
|
56
|
+
oneliner = ""
|
|
57
|
+
functions = ""
|
|
58
|
+
|
|
59
|
+
lines = response.split("\n")
|
|
60
|
+
for i, line in enumerate(lines):
|
|
61
|
+
if line.startswith("ONELINER:"):
|
|
62
|
+
oneliner = line[len("ONELINER:"):].strip()
|
|
63
|
+
elif line.startswith("FUNCTIONS:"):
|
|
64
|
+
functions = "\n".join(lines[i + 1:]).strip()
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
if not oneliner:
|
|
68
|
+
for line in lines:
|
|
69
|
+
s = line.strip()
|
|
70
|
+
if s and not s.startswith("#"):
|
|
71
|
+
oneliner = s[:80]
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
return oneliner, functions
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── Single-file path (sequential fallback) ─────────────────────────────────
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def index_file(
|
|
81
|
+
content: str,
|
|
82
|
+
relative_path: str,
|
|
83
|
+
language: Language,
|
|
84
|
+
model: str = DEFAULT_MODEL,
|
|
85
|
+
api_key: str | None = None,
|
|
86
|
+
) -> tuple[str, str, LLMUsage]:
|
|
87
|
+
"""Index a single file (tier-1 + tier-2 in one call when applicable).
|
|
88
|
+
|
|
89
|
+
Returns (oneliner, functions_text, usage). ``functions_text`` is always
|
|
90
|
+
empty when ``language.build_tier2`` is False. ``usage`` is zero-filled
|
|
91
|
+
when no LLM call succeeded (e.g. empty file, all retries failed).
|
|
92
|
+
"""
|
|
93
|
+
if not content.strip():
|
|
94
|
+
return "(empty file)", "", LLMUsage()
|
|
95
|
+
|
|
96
|
+
prompt = prompt_for(language).format(
|
|
97
|
+
language_name=language.name,
|
|
98
|
+
relative_path=relative_path,
|
|
99
|
+
file_content=content,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
total_usage = LLMUsage()
|
|
103
|
+
for attempt in range(2):
|
|
104
|
+
try:
|
|
105
|
+
response, usage = call_llm(prompt, model=model, api_key=api_key)
|
|
106
|
+
total_usage.add(usage)
|
|
107
|
+
if response:
|
|
108
|
+
oneliner, functions = parse_response(response)
|
|
109
|
+
functions = fix_functions_for_language(language, content, functions)
|
|
110
|
+
return oneliner, functions, total_usage
|
|
111
|
+
except litellm.AuthenticationError:
|
|
112
|
+
# Deterministic — retrying just delays the friendly CLI message.
|
|
113
|
+
raise
|
|
114
|
+
except Exception as e:
|
|
115
|
+
_log.warning(
|
|
116
|
+
f" LLM error on {relative_path}: {e}",
|
|
117
|
+
extra={
|
|
118
|
+
"event": "llm.retry",
|
|
119
|
+
"path": relative_path,
|
|
120
|
+
"attempt": attempt + 1,
|
|
121
|
+
"error_type": type(e).__name__,
|
|
122
|
+
"error": str(e),
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
if attempt == 0:
|
|
126
|
+
_log.info(
|
|
127
|
+
f" Retry in {RETRY_DELAY}s...",
|
|
128
|
+
extra={"event": "llm.retry_wait", "path": relative_path, "delay_s": RETRY_DELAY},
|
|
129
|
+
)
|
|
130
|
+
time.sleep(RETRY_DELAY)
|
|
131
|
+
|
|
132
|
+
_log.error(
|
|
133
|
+
f" LLM failed for {relative_path}, skipping",
|
|
134
|
+
extra={"event": "llm.failed", "path": relative_path},
|
|
135
|
+
)
|
|
136
|
+
return "(LLM summary failed)", "", total_usage
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── Batch dispatch ─────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def build_batch_requests(file_contents: dict[str, tuple[str, str, Language]], model: str = DEFAULT_MODEL) -> list[dict]:
|
|
143
|
+
"""Build per-file LLM requests for concurrent dispatch.
|
|
144
|
+
|
|
145
|
+
file_contents: {content_hash: (file_content, relative_path, language)}
|
|
146
|
+
Uses the tier-1-only prompt for languages where build_tier2 is False.
|
|
147
|
+
"""
|
|
148
|
+
requests = []
|
|
149
|
+
for h, (content, rel_path, language) in file_contents.items():
|
|
150
|
+
prompt = prompt_for(language).format(
|
|
151
|
+
language_name=language.name,
|
|
152
|
+
relative_path=rel_path,
|
|
153
|
+
file_content=content,
|
|
154
|
+
)
|
|
155
|
+
requests.append({
|
|
156
|
+
"custom_id": h,
|
|
157
|
+
"model": model,
|
|
158
|
+
"max_tokens": BATCH_MAX_TOKENS,
|
|
159
|
+
"prompt": prompt,
|
|
160
|
+
})
|
|
161
|
+
return requests
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _ensure_fd_capacity(needed: int) -> int:
|
|
165
|
+
"""Raise this process's soft fd limit to fit ``needed`` open sockets,
|
|
166
|
+
plus a small headroom for stdio/log/cache fds. Returns the effective
|
|
167
|
+
worker capacity after the raise — may be less than ``needed`` if the
|
|
168
|
+
hard limit is lower (the caller must clamp). No-op on platforms
|
|
169
|
+
without a ``resource`` module (Windows)."""
|
|
170
|
+
try:
|
|
171
|
+
import resource
|
|
172
|
+
except ImportError:
|
|
173
|
+
return needed
|
|
174
|
+
HEADROOM = 64
|
|
175
|
+
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
|
176
|
+
target = needed + HEADROOM
|
|
177
|
+
if soft >= target:
|
|
178
|
+
return needed
|
|
179
|
+
new_soft = min(target, hard) if hard > 0 else target
|
|
180
|
+
try:
|
|
181
|
+
resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, hard))
|
|
182
|
+
except (ValueError, OSError):
|
|
183
|
+
return max(1, soft - HEADROOM)
|
|
184
|
+
return max(1, new_soft - HEADROOM)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ── Progress UI ────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _format_eta(seconds: float) -> str:
|
|
191
|
+
"""Human-friendly elapsed/ETA: seconds under 90s, otherwise minutes."""
|
|
192
|
+
if seconds < 90:
|
|
193
|
+
return f"~{int(round(seconds))}s"
|
|
194
|
+
minutes = seconds / 60.0
|
|
195
|
+
if minutes < 10:
|
|
196
|
+
return f"~{minutes:.1f} min"
|
|
197
|
+
return f"~{int(round(minutes))} min"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _render_progress_bar(done: int, total: int, elapsed: float, *, width: int = 28) -> str:
|
|
201
|
+
pct = (done / total) if total else 1.0
|
|
202
|
+
filled = int(pct * width)
|
|
203
|
+
bar = "█" * filled + "░" * (width - filled)
|
|
204
|
+
rate = done / elapsed if elapsed > 0 else 0.0
|
|
205
|
+
if rate > 0 and done < total:
|
|
206
|
+
eta = _format_eta((total - done) / rate)
|
|
207
|
+
else:
|
|
208
|
+
eta = "—"
|
|
209
|
+
return (
|
|
210
|
+
f"[{bar}] {int(pct * 100):3d}% "
|
|
211
|
+
f"elapsed {_format_eta(elapsed)} ETA {eta}"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
_progress_writer: "Callable[[str], None] | None" = None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def set_progress_writer(fn: "Callable[[str], None] | None") -> None:
|
|
219
|
+
"""Register a sink that receives every progress line. Used by the
|
|
220
|
+
daemon to forward progress to a file the CLI can tail; in-process
|
|
221
|
+
callers (build_index_with_store invoked directly) can leave it unset."""
|
|
222
|
+
global _progress_writer
|
|
223
|
+
_progress_writer = fn
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def emit_status(line: str) -> None:
|
|
227
|
+
"""One-shot status emit to the progress side channel. Use for phases
|
|
228
|
+
without per-item progress (e.g. post-batch finalization) so the CLI's
|
|
229
|
+
bar viewer doesn't appear frozen between the last LLM result and
|
|
230
|
+
'Index written to …'."""
|
|
231
|
+
if _progress_writer is not None:
|
|
232
|
+
try:
|
|
233
|
+
_progress_writer(line)
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _emit_progress(label: str, done: int, total: int, elapsed: float, *, is_tty: bool, final: bool = False) -> None:
|
|
239
|
+
line = f"[{label}] {_render_progress_bar(done, total, elapsed)}"
|
|
240
|
+
if _progress_writer is not None:
|
|
241
|
+
try:
|
|
242
|
+
_progress_writer(line)
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
if is_tty and not final:
|
|
246
|
+
sys.stderr.write("\r\033[K" + line)
|
|
247
|
+
sys.stderr.flush()
|
|
248
|
+
else:
|
|
249
|
+
# Non-TTY: emit on milestones so logs aren't spammed but still show motion.
|
|
250
|
+
step = max(1, total // 20)
|
|
251
|
+
if final or done == total or done % step == 0:
|
|
252
|
+
print(line, file=sys.stderr)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def submit_and_wait_batch(
|
|
256
|
+
requests: list[dict],
|
|
257
|
+
api_key: str,
|
|
258
|
+
label: str = "batch",
|
|
259
|
+
*,
|
|
260
|
+
workers: int = REALTIME_MAX_WORKERS,
|
|
261
|
+
) -> tuple[dict[str, str], LLMUsage]:
|
|
262
|
+
"""Fan out requests concurrently through the LiteLLM adapter.
|
|
263
|
+
|
|
264
|
+
Returns ({custom_id: response_text}, aggregated_usage). Usage covers
|
|
265
|
+
every successful call across the pool; failed-after-retries calls drop
|
|
266
|
+
out (their attempts' usage is also lost — this matches the existing
|
|
267
|
+
"results dict" behaviour).
|
|
268
|
+
"""
|
|
269
|
+
requested_pool = max(1, min(workers, len(requests)))
|
|
270
|
+
fd_cap = _ensure_fd_capacity(requested_pool)
|
|
271
|
+
pool_size = min(requested_pool, fd_cap)
|
|
272
|
+
if pool_size < requested_pool:
|
|
273
|
+
_log.warning(
|
|
274
|
+
f"[{label}] fd hard limit too low for {requested_pool} workers; "
|
|
275
|
+
f"clamping to {pool_size}. Raise `ulimit -n` to use more.",
|
|
276
|
+
extra={
|
|
277
|
+
"event": "batch.fd_clamp",
|
|
278
|
+
"label": label,
|
|
279
|
+
"requested": requested_pool,
|
|
280
|
+
"clamped_to": pool_size,
|
|
281
|
+
},
|
|
282
|
+
)
|
|
283
|
+
# Heuristic: ~5s/request amortized over the worker pool. The inline bar refines this once real rates are known.
|
|
284
|
+
est_seconds = (len(requests) / max(1, pool_size)) * 5.0
|
|
285
|
+
_log.info(
|
|
286
|
+
f"[{label}] Dispatching {len(requests)} requests "
|
|
287
|
+
f"(workers={pool_size}, est build time {_format_eta(est_seconds)})...",
|
|
288
|
+
extra={
|
|
289
|
+
"event": "batch.dispatch",
|
|
290
|
+
"label": label,
|
|
291
|
+
"requests": len(requests),
|
|
292
|
+
"workers": pool_size,
|
|
293
|
+
"est_seconds": round(est_seconds, 1),
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def _one(req: dict) -> tuple[str, str | None, LLMUsage]:
|
|
298
|
+
cid = req["custom_id"]
|
|
299
|
+
last_err: Exception | None = None
|
|
300
|
+
attempt_usage = LLMUsage()
|
|
301
|
+
for attempt in range(REALTIME_MAX_RETRIES + 1):
|
|
302
|
+
try:
|
|
303
|
+
text, usage = call_llm(
|
|
304
|
+
req["prompt"],
|
|
305
|
+
model=req["model"],
|
|
306
|
+
api_key=api_key,
|
|
307
|
+
max_tokens=req["max_tokens"],
|
|
308
|
+
)
|
|
309
|
+
attempt_usage.add(usage)
|
|
310
|
+
return cid, text or "", attempt_usage
|
|
311
|
+
except litellm.AuthenticationError:
|
|
312
|
+
# Deterministic; let the outer loop cancel siblings.
|
|
313
|
+
raise
|
|
314
|
+
except Exception as e: # network/rate-limit/5xx
|
|
315
|
+
last_err = e
|
|
316
|
+
if attempt < REALTIME_MAX_RETRIES:
|
|
317
|
+
time.sleep(RETRY_DELAY * (attempt + 1))
|
|
318
|
+
_log.error(
|
|
319
|
+
f"[{label}] {cid}: failed after retries ({last_err})",
|
|
320
|
+
extra={
|
|
321
|
+
"event": "llm.failed",
|
|
322
|
+
"label": label,
|
|
323
|
+
"custom_id": cid,
|
|
324
|
+
"attempts": REALTIME_MAX_RETRIES + 1,
|
|
325
|
+
"error_type": type(last_err).__name__ if last_err else None,
|
|
326
|
+
"error": str(last_err) if last_err else None,
|
|
327
|
+
},
|
|
328
|
+
)
|
|
329
|
+
return cid, None, attempt_usage
|
|
330
|
+
|
|
331
|
+
results: dict[str, str] = {}
|
|
332
|
+
total_usage = LLMUsage()
|
|
333
|
+
succeeded = 0
|
|
334
|
+
failed = 0
|
|
335
|
+
total = len(requests)
|
|
336
|
+
start = time.time()
|
|
337
|
+
is_tty = sys.stderr.isatty()
|
|
338
|
+
|
|
339
|
+
with ThreadPoolExecutor(max_workers=pool_size) as pool:
|
|
340
|
+
# Materialize the futures list so we can cancel queued tasks on a
|
|
341
|
+
# fatal error. Default `with`-exit waits for everything to finish.
|
|
342
|
+
futures = [pool.submit(_one, r) for r in requests]
|
|
343
|
+
try:
|
|
344
|
+
for fut in as_completed(futures):
|
|
345
|
+
cid, text, usage = fut.result()
|
|
346
|
+
total_usage.add(usage)
|
|
347
|
+
if text is not None:
|
|
348
|
+
results[cid] = text
|
|
349
|
+
succeeded += 1
|
|
350
|
+
else:
|
|
351
|
+
failed += 1
|
|
352
|
+
done = succeeded + failed
|
|
353
|
+
_emit_progress(label, done, total, time.time() - start, is_tty=is_tty)
|
|
354
|
+
except litellm.AuthenticationError:
|
|
355
|
+
# In-flight threads can't be interrupted, but cancelling drains
|
|
356
|
+
# the queue so the remaining ~(total − pool_size) requests don't
|
|
357
|
+
# fire after we know the key is rejected.
|
|
358
|
+
for f in futures:
|
|
359
|
+
f.cancel()
|
|
360
|
+
raise
|
|
361
|
+
|
|
362
|
+
if is_tty:
|
|
363
|
+
sys.stderr.write("\n")
|
|
364
|
+
sys.stderr.flush()
|
|
365
|
+
_log.info(
|
|
366
|
+
f"[{label}] Results: {succeeded} succeeded, {failed} failed",
|
|
367
|
+
extra={
|
|
368
|
+
"event": "batch.results",
|
|
369
|
+
"label": label,
|
|
370
|
+
"succeeded": succeeded,
|
|
371
|
+
"failed": failed,
|
|
372
|
+
"elapsed_s": round(time.time() - start, 3),
|
|
373
|
+
"input_tokens": total_usage.input_tokens,
|
|
374
|
+
"output_tokens": total_usage.output_tokens,
|
|
375
|
+
"cost_usd": round(total_usage.cost_usd, 6),
|
|
376
|
+
},
|
|
377
|
+
)
|
|
378
|
+
return results, total_usage
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def index_by_hash(
|
|
382
|
+
hash_to_content: dict[str, tuple[str, str, Language]],
|
|
383
|
+
*,
|
|
384
|
+
summary_cache: dict[str, dict[str, str]],
|
|
385
|
+
model: str,
|
|
386
|
+
api_key: str,
|
|
387
|
+
workers: int,
|
|
388
|
+
label: str,
|
|
389
|
+
) -> tuple[dict[str, tuple[str, str]], LLMUsage]:
|
|
390
|
+
"""Fan content-deduped requests out concurrently, parse + line-range-fix
|
|
391
|
+
responses, and merge into ``summary_cache``. Returns
|
|
392
|
+
``(hash_to_parsed, usage)`` mapping each successful response's content
|
|
393
|
+
hash to its ``(oneliner, functions)`` tuple."""
|
|
394
|
+
requests = build_batch_requests(hash_to_content, model=model)
|
|
395
|
+
results, usage = submit_and_wait_batch(requests, api_key, label=label, workers=workers)
|
|
396
|
+
hash_to_parsed: dict[str, tuple[str, str]] = {}
|
|
397
|
+
for h, text in results.items():
|
|
398
|
+
oneliner, functions = parse_response(text)
|
|
399
|
+
content, _, lang = hash_to_content[h]
|
|
400
|
+
functions = fix_functions_for_language(lang, content, functions)
|
|
401
|
+
hash_to_parsed[h] = (oneliner, functions)
|
|
402
|
+
summary_cache[h] = {"oneliner": oneliner, "functions": functions}
|
|
403
|
+
return hash_to_parsed, usage
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Post-LLM line-range correction.
|
|
2
|
+
|
|
3
|
+
The build pipeline calls ``fix_functions_for_language`` after the LLM
|
|
4
|
+
returns its tier-2 entries. For Python we use the stdlib ``ast``; for
|
|
5
|
+
other languages we fall through to tree-sitter. Both fixers replace the
|
|
6
|
+
LLM's ``(Lstart-Lend)`` ranges with parser-truth ranges so the agent's
|
|
7
|
+
Read calls land on real function boundaries.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ...lib.languages import Language
|
|
11
|
+
from .python_ast import fix_python_line_ranges, fill_missing_python_classes
|
|
12
|
+
from .treesitter import fix_line_ranges as _fix_treesitter_line_ranges
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def fix_functions_for_language(lang: Language, content: str, functions: str) -> str:
|
|
16
|
+
"""Apply language-specific line-range fixes to LLM-emitted functions text.
|
|
17
|
+
No-op when ``functions`` is empty."""
|
|
18
|
+
if not functions:
|
|
19
|
+
return functions
|
|
20
|
+
if lang.key == "python":
|
|
21
|
+
functions = fix_python_line_ranges(content, functions)
|
|
22
|
+
functions = fill_missing_python_classes(content, functions)
|
|
23
|
+
return functions
|
|
24
|
+
return _fix_treesitter_line_ranges(lang.key, content, functions)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Python AST-grounded line-range fixer for tier-2 LLM output.
|
|
2
|
+
|
|
3
|
+
Cheaper LLMs (gemini, mistral, ds-v4-flash) emit roughly correct names but
|
|
4
|
+
fabricate line ranges. We replace LLM line numbers with AST ground truth
|
|
5
|
+
whenever the qualified name matches. Description quality is unchanged.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_LINE_RANGE_RE = re.compile(r"\(L?\d+\s*-\s*L?\d+\)")
|
|
13
|
+
_ENTRY_RE = re.compile(r"^([\w.:_<>]+)\s+\(L?\d+\s*-\s*L?\d+\)\s*:?")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _python_ast_truth(source: str) -> dict[str, tuple[int, int]]:
|
|
17
|
+
"""{qualified_name: (start_lineno, end_lineno)} for every def in source.
|
|
18
|
+
|
|
19
|
+
Methods qualified as Class.method, nested funcs as Outer.inner AND
|
|
20
|
+
Outer.<locals>.inner so either naming convention matches."""
|
|
21
|
+
try:
|
|
22
|
+
tree = ast.parse(source)
|
|
23
|
+
except (SyntaxError, ValueError):
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
out: dict[str, tuple[int, int]] = {}
|
|
27
|
+
|
|
28
|
+
def walk(node, prefix: str = ""):
|
|
29
|
+
for child in ast.iter_child_nodes(node):
|
|
30
|
+
if isinstance(child, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
31
|
+
qname = f"{prefix}{child.name}" if prefix else child.name
|
|
32
|
+
out[qname] = (child.lineno, child.end_lineno or child.lineno)
|
|
33
|
+
walk(child, prefix=f"{qname}.")
|
|
34
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
35
|
+
walk(child, prefix=f"{qname}.<locals>.")
|
|
36
|
+
|
|
37
|
+
walk(tree)
|
|
38
|
+
return out
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fix_python_line_ranges(source: str, functions_text: str) -> str:
|
|
42
|
+
"""Replace LLM-emitted line ranges with AST ground truth for Python files.
|
|
43
|
+
|
|
44
|
+
Lines whose qualified name matches an AST def get their (Lstart-Lend)
|
|
45
|
+
rewritten to the real range. Unmatched names (likely hallucinations or
|
|
46
|
+
deeply nested helpers AST didn't enumerate) are left untouched — kept
|
|
47
|
+
so a downstream judge can flag them, not silently dropped."""
|
|
48
|
+
truth = _python_ast_truth(source)
|
|
49
|
+
if not truth:
|
|
50
|
+
return functions_text
|
|
51
|
+
|
|
52
|
+
out_lines: list[str] = []
|
|
53
|
+
for line in functions_text.splitlines():
|
|
54
|
+
m = _ENTRY_RE.match(line.strip())
|
|
55
|
+
if not m:
|
|
56
|
+
out_lines.append(line)
|
|
57
|
+
continue
|
|
58
|
+
qname = m.group(1).rstrip(".")
|
|
59
|
+
match_key = qname if qname in truth else None
|
|
60
|
+
if match_key is None:
|
|
61
|
+
for k in truth:
|
|
62
|
+
if k.endswith(qname) or qname.endswith(k):
|
|
63
|
+
match_key = k
|
|
64
|
+
break
|
|
65
|
+
if match_key is None:
|
|
66
|
+
out_lines.append(line)
|
|
67
|
+
continue
|
|
68
|
+
ls, le = truth[match_key]
|
|
69
|
+
new_line = _LINE_RANGE_RE.sub(f"(L{ls}-L{le})", line, count=1)
|
|
70
|
+
if match_key != qname:
|
|
71
|
+
new_line = new_line.replace(qname, match_key, 1)
|
|
72
|
+
out_lines.append(new_line)
|
|
73
|
+
return "\n".join(out_lines)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def fill_missing_python_classes(source: str, functions_text: str) -> str:
|
|
77
|
+
"""Defense-in-depth: inject class entries the LLM omitted but AST shows exist.
|
|
78
|
+
|
|
79
|
+
The current INDEX_PROMPT_TEMPLATE has class-specific examples and a
|
|
80
|
+
purpose-vs-behavior rubric, so gpt-5.4-nano + the in-tree prompt
|
|
81
|
+
produce full coverage on its own. This function is a no-op when the
|
|
82
|
+
LLM already lists every class. It's kept for two reasons: (a) cheaper
|
|
83
|
+
or differently-trained models may still drop class lines, (b) prompt
|
|
84
|
+
wording can drift in future commits and we don't want a regression to
|
|
85
|
+
silently lose class-level overview from the roadmap. Existing entries
|
|
86
|
+
are preserved verbatim — we only ADD lines, never modify."""
|
|
87
|
+
try:
|
|
88
|
+
tree = ast.parse(source)
|
|
89
|
+
except (SyntaxError, ValueError):
|
|
90
|
+
return functions_text
|
|
91
|
+
|
|
92
|
+
# Collect class defs with their method count + docstring oneliner.
|
|
93
|
+
class_defs: list[tuple[str, int, int, str]] = [] # (qname, ls, le, summary)
|
|
94
|
+
|
|
95
|
+
def walk_classes(node, prefix: str = ""):
|
|
96
|
+
for child in ast.iter_child_nodes(node):
|
|
97
|
+
if isinstance(child, ast.ClassDef):
|
|
98
|
+
qname = f"{prefix}{child.name}" if prefix else child.name
|
|
99
|
+
docstring = ast.get_docstring(child) or ""
|
|
100
|
+
first_line = (docstring.split("\n", 1)[0] or "").strip()
|
|
101
|
+
methods = sum(
|
|
102
|
+
1 for c in child.body
|
|
103
|
+
if isinstance(c, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
104
|
+
)
|
|
105
|
+
if first_line:
|
|
106
|
+
summary = first_line[:90]
|
|
107
|
+
else:
|
|
108
|
+
bases = [
|
|
109
|
+
ast.unparse(b) if hasattr(ast, "unparse") else getattr(b, "id", "?")
|
|
110
|
+
for b in child.bases
|
|
111
|
+
]
|
|
112
|
+
base_part = f"({', '.join(bases)})" if bases else ""
|
|
113
|
+
summary = f"Class {child.name}{base_part} with {methods} method(s)"
|
|
114
|
+
class_defs.append((
|
|
115
|
+
qname,
|
|
116
|
+
child.lineno,
|
|
117
|
+
child.end_lineno or child.lineno,
|
|
118
|
+
summary,
|
|
119
|
+
))
|
|
120
|
+
walk_classes(child, prefix=f"{qname}.")
|
|
121
|
+
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
122
|
+
walk_classes(child, prefix=f"{prefix}{child.name}.<locals>.")
|
|
123
|
+
|
|
124
|
+
walk_classes(tree)
|
|
125
|
+
if not class_defs:
|
|
126
|
+
return functions_text
|
|
127
|
+
|
|
128
|
+
# Find which class names already appear in the LLM output.
|
|
129
|
+
present = set()
|
|
130
|
+
for line in functions_text.splitlines():
|
|
131
|
+
m = _ENTRY_RE.match(line.strip())
|
|
132
|
+
if m:
|
|
133
|
+
present.add(m.group(1).rstrip("."))
|
|
134
|
+
|
|
135
|
+
# Inject missing classes immediately before the first method that
|
|
136
|
+
# belongs to them (so file order is preserved). Classes with no method
|
|
137
|
+
# yet listed get appended at the end.
|
|
138
|
+
out_lines = list(functions_text.splitlines())
|
|
139
|
+
for qname, ls, le, summary in class_defs:
|
|
140
|
+
if qname in present:
|
|
141
|
+
continue
|
|
142
|
+
new_entry = f"{qname} (L{ls}-L{le}): {summary}"
|
|
143
|
+
# Find first line whose qname starts with `qname.`
|
|
144
|
+
method_prefix = f"{qname}."
|
|
145
|
+
injected = False
|
|
146
|
+
for i, line in enumerate(out_lines):
|
|
147
|
+
m = _ENTRY_RE.match(line.strip())
|
|
148
|
+
if m and m.group(1).startswith(method_prefix):
|
|
149
|
+
out_lines.insert(i, new_entry)
|
|
150
|
+
injected = True
|
|
151
|
+
break
|
|
152
|
+
if not injected:
|
|
153
|
+
out_lines.append(new_entry)
|
|
154
|
+
|
|
155
|
+
return "\n".join(out_lines)
|