seschat 0.1.0b2__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.
- seschat/__init__.py +3 -0
- seschat/ask.py +439 -0
- seschat/cli.py +451 -0
- seschat/db.py +193 -0
- seschat/embeddings.py +172 -0
- seschat/explain.py +201 -0
- seschat/gitignore.py +113 -0
- seschat/llm.py +203 -0
- seschat/metadata.py +453 -0
- seschat/scanner.py +259 -0
- seschat/search.py +214 -0
- seschat/semantic.py +244 -0
- seschat-0.1.0b2.dist-info/METADATA +220 -0
- seschat-0.1.0b2.dist-info/RECORD +18 -0
- seschat-0.1.0b2.dist-info/WHEEL +5 -0
- seschat-0.1.0b2.dist-info/entry_points.txt +2 -0
- seschat-0.1.0b2.dist-info/licenses/LICENSE +21 -0
- seschat-0.1.0b2.dist-info/top_level.txt +1 -0
seschat/__init__.py
ADDED
seschat/ask.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ask.py — Stage 5 + Stage 7 of Seschat: `seschat ask <path> "<question>"`.
|
|
3
|
+
|
|
4
|
+
Stage 5 pipeline (roadmap.md's sketch, implemented literally):
|
|
5
|
+
|
|
6
|
+
question
|
|
7
|
+
-> search repository (search.py — plain SQL, no AI)
|
|
8
|
+
-> collect relevant files (this module)
|
|
9
|
+
-> construct prompt (this module)
|
|
10
|
+
-> LLM (llm.py)
|
|
11
|
+
-> answer
|
|
12
|
+
|
|
13
|
+
Stage 7 changes the *retrieval* step only: instead of keyword search
|
|
14
|
+
alone, `ask` now runs BOTH Stage 4's keyword search and Stage 6's
|
|
15
|
+
semantic (embedding) search and merges them into one ranked candidate
|
|
16
|
+
list — roadmap.md's Stage 7 pipeline:
|
|
17
|
+
|
|
18
|
+
question -> embedding -> vector search -> relevant chunks
|
|
19
|
+
-> (also) keyword search
|
|
20
|
+
-> merged, ranked candidates -> prompt builder -> LLM -> answer
|
|
21
|
+
|
|
22
|
+
Retrieval still happens entirely outside the model. The LLM never
|
|
23
|
+
decides what to look at — it only reads what Python already selected,
|
|
24
|
+
now by two complementary signals instead of one: keyword search catches
|
|
25
|
+
exact identifier/name matches; semantic search catches conceptually
|
|
26
|
+
related files that don't share any words with the question. If the
|
|
27
|
+
repo has no embedding index (or the embedding backend isn't reachable),
|
|
28
|
+
retrieval degrades to keyword-only and that degradation is reported via
|
|
29
|
+
AskResult.semantic_note rather than failing silently or hard-erroring —
|
|
30
|
+
`ask` should still work on a repo indexed with `--no-embeddings`.
|
|
31
|
+
|
|
32
|
+
"Chunks" in the roadmap sketch are still whole files, not sub-file
|
|
33
|
+
spans — same file-level granularity Stage 6 already documented as a
|
|
34
|
+
deliberate simplification (metadata.py doesn't track byte ranges yet).
|
|
35
|
+
Stage 7 improves *which* files get retrieved, not the granularity of
|
|
36
|
+
what's retrieved; per-chunk embeddings remain a Stage 6/7 follow-up
|
|
37
|
+
noted in the README.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import re
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
from seschat.embeddings import EmbeddingError, EmbeddingNotConfiguredError
|
|
47
|
+
from seschat.llm import call_llm
|
|
48
|
+
from seschat.search import IndexNotFoundError, Match, get_file_structure, search_repository
|
|
49
|
+
from seschat.semantic import EmbeddingsNotFoundError, semantic_search
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"extract_keywords",
|
|
53
|
+
"collect_relevant_files",
|
|
54
|
+
"retrieve_relevant_files",
|
|
55
|
+
"build_file_contexts",
|
|
56
|
+
"build_prompt",
|
|
57
|
+
"ask_repository",
|
|
58
|
+
"AskResult",
|
|
59
|
+
"FileContext",
|
|
60
|
+
"RankedFile",
|
|
61
|
+
"RetrievedFile",
|
|
62
|
+
"IndexNotFoundError",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
# --------------------------------------------------------------------------
|
|
66
|
+
# Step 1: turn a natural-language question into search keywords.
|
|
67
|
+
# --------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
# Small stopword list — just enough to filter out the words that show up
|
|
70
|
+
# in almost every question ("where", "is", "the", ...) and would otherwise
|
|
71
|
+
# get searched as if they were meaningful repo terms. Not linguistically
|
|
72
|
+
# rigorous; good enough for Stage 5's keyword extraction. Real ranking
|
|
73
|
+
# (TF-IDF, etc.) is still deferred, same as Stage 4.
|
|
74
|
+
_STOPWORDS = {
|
|
75
|
+
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
|
|
76
|
+
"where", "what", "when", "who", "how", "why", "which", "does", "do",
|
|
77
|
+
"did", "can", "could", "would", "should", "will", "shall",
|
|
78
|
+
"this", "that", "these", "those", "there", "here",
|
|
79
|
+
"in", "on", "at", "to", "for", "of", "with", "by", "from", "as",
|
|
80
|
+
"and", "or", "but", "not", "no", "it", "its", "i", "we", "you",
|
|
81
|
+
"my", "our", "me", "us", "about", "into", "if", "so", "than",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_WORD_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def extract_keywords(question: str) -> list[str]:
|
|
88
|
+
"""
|
|
89
|
+
Pull search-worthy tokens out of a natural-language question.
|
|
90
|
+
|
|
91
|
+
Deliberately simple (split on word boundaries, drop stopwords and
|
|
92
|
+
very short tokens, de-duplicate preserving order) rather than real
|
|
93
|
+
NLP — this is still "no AI" territory per roadmap.md; the model only
|
|
94
|
+
gets involved *after* retrieval. Identifiers like `CamelCase` or
|
|
95
|
+
`snake_case` survive intact since they're exactly the kind of term
|
|
96
|
+
likely to match a class/function name.
|
|
97
|
+
"""
|
|
98
|
+
seen: dict[str, None] = {}
|
|
99
|
+
for word in _WORD_RE.findall(question):
|
|
100
|
+
if word.lower() in _STOPWORDS or len(word) < 3:
|
|
101
|
+
continue
|
|
102
|
+
seen.setdefault(word, None)
|
|
103
|
+
return list(seen.keys())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --------------------------------------------------------------------------
|
|
107
|
+
# Step 2a: Stage 4 keyword retrieval (unchanged logic, still used
|
|
108
|
+
# standalone by --no-semantic and as one half of Stage 7's merge).
|
|
109
|
+
# --------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class RankedFile:
|
|
113
|
+
"""One file's aggregated keyword relevance across every keyword searched."""
|
|
114
|
+
path: str
|
|
115
|
+
language: str
|
|
116
|
+
total_matches: int = 0
|
|
117
|
+
matched_keywords: set[str] = field(default_factory=set)
|
|
118
|
+
per_keyword_results: list = field(default_factory=list) # list[FileResult]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def collect_relevant_files(
|
|
122
|
+
db_path: Path, question: str, max_files: int = 6
|
|
123
|
+
) -> tuple[list[str], list[RankedFile]]:
|
|
124
|
+
"""
|
|
125
|
+
Run Stage 4's search_repository() once per extracted keyword and merge
|
|
126
|
+
the results: a file's overall rank is the sum of its match counts
|
|
127
|
+
across every keyword that hit it, so a file matching several
|
|
128
|
+
question-keywords outranks one matching only one. Raises
|
|
129
|
+
IndexNotFoundError (from search.py) if the repo hasn't been indexed —
|
|
130
|
+
checked on the first search call, so an un-indexed repo fails fast
|
|
131
|
+
even if the question has several keywords.
|
|
132
|
+
"""
|
|
133
|
+
keywords = extract_keywords(question)
|
|
134
|
+
ranked: dict[str, RankedFile] = {}
|
|
135
|
+
|
|
136
|
+
for keyword in keywords:
|
|
137
|
+
for file_result in search_repository(db_path, keyword, limit=50):
|
|
138
|
+
entry = ranked.setdefault(
|
|
139
|
+
file_result.path,
|
|
140
|
+
RankedFile(path=file_result.path, language=file_result.language),
|
|
141
|
+
)
|
|
142
|
+
entry.total_matches += file_result.match_count
|
|
143
|
+
entry.matched_keywords.add(keyword)
|
|
144
|
+
entry.per_keyword_results.append(file_result)
|
|
145
|
+
|
|
146
|
+
ordered = sorted(ranked.values(), key=lambda r: (-r.total_matches, r.path))
|
|
147
|
+
return keywords, ordered[:max_files]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --------------------------------------------------------------------------
|
|
151
|
+
# Step 2b: Stage 7 — merge keyword ranking with Stage 6 semantic ranking.
|
|
152
|
+
# --------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
_KEYWORD_WEIGHT = 0.5
|
|
155
|
+
_SEMANTIC_WEIGHT = 0.5
|
|
156
|
+
_CANDIDATE_MULTIPLIER = 3 # pull more candidates than max_files from each
|
|
157
|
+
# signal before merging, so a file that's #2 on
|
|
158
|
+
# keywords and #2 on semantics but never #1 on
|
|
159
|
+
# either still has a shot at the final top-k.
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class RetrievedFile:
|
|
164
|
+
"""
|
|
165
|
+
One file's merged relevance, Stage 7. Unlike RankedFile (keyword-only),
|
|
166
|
+
this tracks a score per retrieval signal plus which signal(s) actually
|
|
167
|
+
found it, so --show-context can explain *why* a file was retrieved.
|
|
168
|
+
"""
|
|
169
|
+
path: str
|
|
170
|
+
language: str
|
|
171
|
+
keyword_score: float = 0.0 # normalized 0-1: this file's matches / top file's matches
|
|
172
|
+
semantic_score: float = 0.0 # cosine similarity, already ~0-1
|
|
173
|
+
combined_score: float = 0.0
|
|
174
|
+
matched_keywords: set[str] = field(default_factory=set)
|
|
175
|
+
sources: set[str] = field(default_factory=set) # {"keyword"} / {"semantic"} / both
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _normalized_keyword_scores(ranked: list[RankedFile]) -> dict[str, float]:
|
|
179
|
+
"""Scale raw match counts to 0-1 so they're comparable to cosine similarity."""
|
|
180
|
+
if not ranked:
|
|
181
|
+
return {}
|
|
182
|
+
top = max(r.total_matches for r in ranked) or 1
|
|
183
|
+
return {r.path: r.total_matches / top for r in ranked}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def retrieve_relevant_files(
|
|
187
|
+
db_path: Path,
|
|
188
|
+
question: str,
|
|
189
|
+
max_files: int = 6,
|
|
190
|
+
use_semantic: bool = True,
|
|
191
|
+
) -> tuple[list[str], list[RetrievedFile], str | None]:
|
|
192
|
+
"""
|
|
193
|
+
Stage 7's hybrid retrieval: run keyword search (Stage 4) and, unless
|
|
194
|
+
disabled or unavailable, semantic search (Stage 6) over the same
|
|
195
|
+
question, normalize each signal to 0-1, and merge into one ranked
|
|
196
|
+
candidate list via a simple weighted sum
|
|
197
|
+
(0.5 * keyword_score + 0.5 * semantic_score).
|
|
198
|
+
|
|
199
|
+
Returns (keywords_searched, retrieved_files, semantic_note).
|
|
200
|
+
`semantic_note` is None when semantic search ran and contributed
|
|
201
|
+
normally; otherwise it's a short human-readable explanation of why
|
|
202
|
+
it didn't (disabled via --no-semantic, no embedding index yet, or
|
|
203
|
+
the embedding backend that built the index isn't reachable right
|
|
204
|
+
now) — surfaced by `--show-context` so a keyword-only fallback is
|
|
205
|
+
visible rather than silent.
|
|
206
|
+
|
|
207
|
+
Raises IndexNotFoundError if the repo hasn't been indexed at all
|
|
208
|
+
(propagated from collect_relevant_files/search_repository, checked
|
|
209
|
+
before semantic search runs — there's no point calling an embedding
|
|
210
|
+
backend against a database that doesn't exist).
|
|
211
|
+
"""
|
|
212
|
+
keywords, keyword_ranked = collect_relevant_files(
|
|
213
|
+
db_path, question, max_files=max_files * _CANDIDATE_MULTIPLIER
|
|
214
|
+
)
|
|
215
|
+
kw_scores = _normalized_keyword_scores(keyword_ranked)
|
|
216
|
+
|
|
217
|
+
combined: dict[str, RetrievedFile] = {}
|
|
218
|
+
for rf in keyword_ranked:
|
|
219
|
+
entry = combined.setdefault(rf.path, RetrievedFile(path=rf.path, language=rf.language))
|
|
220
|
+
entry.keyword_score = kw_scores.get(rf.path, 0.0)
|
|
221
|
+
entry.matched_keywords = set(rf.matched_keywords)
|
|
222
|
+
entry.sources.add("keyword")
|
|
223
|
+
|
|
224
|
+
semantic_note: str | None = None
|
|
225
|
+
if not use_semantic:
|
|
226
|
+
semantic_note = "semantic search disabled (--no-semantic)"
|
|
227
|
+
else:
|
|
228
|
+
try:
|
|
229
|
+
sem_matches = semantic_search(db_path, question, limit=max_files * _CANDIDATE_MULTIPLIER)
|
|
230
|
+
except EmbeddingsNotFoundError as e:
|
|
231
|
+
semantic_note = f"semantic search skipped — {e}"
|
|
232
|
+
except (EmbeddingNotConfiguredError, EmbeddingError) as e:
|
|
233
|
+
semantic_note = f"semantic search unavailable — {e}"
|
|
234
|
+
else:
|
|
235
|
+
for m in sem_matches:
|
|
236
|
+
entry = combined.setdefault(m.path, RetrievedFile(path=m.path, language=m.language))
|
|
237
|
+
entry.semantic_score = max(entry.semantic_score, m.score)
|
|
238
|
+
entry.sources.add("semantic")
|
|
239
|
+
|
|
240
|
+
for entry in combined.values():
|
|
241
|
+
entry.combined_score = (
|
|
242
|
+
_KEYWORD_WEIGHT * entry.keyword_score + _SEMANTIC_WEIGHT * entry.semantic_score
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
ordered = sorted(combined.values(), key=lambda r: (-r.combined_score, r.path))
|
|
246
|
+
return keywords, ordered[:max_files], semantic_note
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# --------------------------------------------------------------------------
|
|
250
|
+
# Step 3: attach real source excerpts + full structure, construct the prompt.
|
|
251
|
+
# --------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
MAX_CHARS_PER_FILE = 3000 # rough cap so one huge file can't crowd out the rest
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclass
|
|
257
|
+
class FileContext:
|
|
258
|
+
path: str
|
|
259
|
+
language: str
|
|
260
|
+
classes: list[str]
|
|
261
|
+
functions: list[str]
|
|
262
|
+
imports: list[str]
|
|
263
|
+
comments: list[str]
|
|
264
|
+
source_excerpt: str | None
|
|
265
|
+
source_truncated: bool
|
|
266
|
+
read_error: str | None = None
|
|
267
|
+
# Stage 7 additions — how this file was retrieved, for transparency.
|
|
268
|
+
sources: list[str] = field(default_factory=list)
|
|
269
|
+
keyword_score: float = 0.0
|
|
270
|
+
semantic_score: float = 0.0
|
|
271
|
+
combined_score: float = 0.0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _load_source_excerpt(root: Path, rel_path: str) -> tuple[str | None, bool, str | None]:
|
|
275
|
+
"""
|
|
276
|
+
Best-effort read of a file's actual source, for prompt grounding. The
|
|
277
|
+
DB only stores *extracted* structure (Stage 2/3), never raw file
|
|
278
|
+
bytes, so getting real source means reading it fresh off disk —
|
|
279
|
+
which also means this can fail if the file moved/was deleted since
|
|
280
|
+
the last `seschat index` run; that's reported, not raised.
|
|
281
|
+
"""
|
|
282
|
+
full = root / rel_path
|
|
283
|
+
try:
|
|
284
|
+
text = full.read_text(errors="replace")
|
|
285
|
+
except OSError as e:
|
|
286
|
+
return None, False, f"could not read file: {e}"
|
|
287
|
+
|
|
288
|
+
if len(text) > MAX_CHARS_PER_FILE:
|
|
289
|
+
return text[:MAX_CHARS_PER_FILE], True, None
|
|
290
|
+
return text, False, None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def build_file_contexts(
|
|
294
|
+
root: Path, db_path: Path, retrieved_files: list[RetrievedFile]
|
|
295
|
+
) -> list[FileContext]:
|
|
296
|
+
"""
|
|
297
|
+
For each retrieved file, look up its FULL extracted structure via
|
|
298
|
+
search.get_file_structure() — a change from Stage 5, which only
|
|
299
|
+
reused whatever partial structure happened to match the search
|
|
300
|
+
keywords. That worked when retrieval was keyword-only (a matched
|
|
301
|
+
class name implied that class was relevant), but semantic-only
|
|
302
|
+
files have no matched names to reuse at all, so Stage 7 always does
|
|
303
|
+
the direct-by-path lookup for every file, keyword-matched or not.
|
|
304
|
+
Source excerpts are still read fresh off disk, same as Stage 5.
|
|
305
|
+
"""
|
|
306
|
+
contexts = []
|
|
307
|
+
for rf in retrieved_files:
|
|
308
|
+
classes, functions, imports, comments = get_file_structure(db_path, rf.path)
|
|
309
|
+
source, truncated, err = _load_source_excerpt(root, rf.path)
|
|
310
|
+
contexts.append(
|
|
311
|
+
FileContext(
|
|
312
|
+
path=rf.path,
|
|
313
|
+
language=rf.language,
|
|
314
|
+
classes=classes,
|
|
315
|
+
functions=functions,
|
|
316
|
+
imports=imports,
|
|
317
|
+
comments=comments,
|
|
318
|
+
source_excerpt=source,
|
|
319
|
+
source_truncated=truncated,
|
|
320
|
+
read_error=err,
|
|
321
|
+
sources=sorted(rf.sources),
|
|
322
|
+
keyword_score=rf.keyword_score,
|
|
323
|
+
semantic_score=rf.semantic_score,
|
|
324
|
+
combined_score=rf.combined_score,
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
return contexts
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
SYSTEM_PROMPT = (
|
|
331
|
+
"You are a repository question-answering assistant. Answer the "
|
|
332
|
+
"question using ONLY the file excerpts and extracted metadata "
|
|
333
|
+
"provided below — they were retrieved by keyword and/or semantic "
|
|
334
|
+
"search over the repository, not chosen by you. Each file notes "
|
|
335
|
+
"which retrieval method(s) found it; a file found only by semantic "
|
|
336
|
+
"search may be conceptually related even if it shares no words with "
|
|
337
|
+
"the question. If the provided context doesn't contain enough "
|
|
338
|
+
"information to answer confidently, say so plainly instead of "
|
|
339
|
+
"guessing. Cite specific file paths in your answer when you "
|
|
340
|
+
"reference something. Be concise."
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def build_prompt(question: str, keywords: list[str], contexts: list[FileContext]) -> str:
|
|
345
|
+
"""
|
|
346
|
+
Assemble the final user-turn prompt: the question, which keywords
|
|
347
|
+
were searched, then the retrieved file context — in that order, so
|
|
348
|
+
the model sees the question again right before it starts reading.
|
|
349
|
+
Each file section now also states how it was retrieved (Stage 7),
|
|
350
|
+
so the model can weigh an exact keyword hit differently from a
|
|
351
|
+
semantic-only match if that matters to the answer.
|
|
352
|
+
"""
|
|
353
|
+
if not contexts:
|
|
354
|
+
return (
|
|
355
|
+
f"Question: {question}\n\n"
|
|
356
|
+
f"Keywords searched: {', '.join(keywords) or '(none extracted)'}\n\n"
|
|
357
|
+
"No files in the repository index matched any of these keywords "
|
|
358
|
+
"or were found via semantic search. Tell the user plainly that "
|
|
359
|
+
"retrieval found nothing relevant, rather than guessing at an "
|
|
360
|
+
"answer."
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
sections = [f"Question: {question}", f"Keywords searched: {', '.join(keywords)}", ""]
|
|
364
|
+
for ctx in contexts:
|
|
365
|
+
sections.append(f"--- {ctx.path} ({ctx.language}) ---")
|
|
366
|
+
sections.append(
|
|
367
|
+
f"Retrieved via: {', '.join(ctx.sources)} "
|
|
368
|
+
f"(keyword score {ctx.keyword_score:.2f}, semantic score {ctx.semantic_score:.2f})"
|
|
369
|
+
)
|
|
370
|
+
if ctx.classes:
|
|
371
|
+
sections.append(f"Classes: {', '.join(ctx.classes)}")
|
|
372
|
+
if ctx.functions:
|
|
373
|
+
sections.append(f"Functions: {', '.join(ctx.functions)}")
|
|
374
|
+
if ctx.imports:
|
|
375
|
+
sections.append(f"Imports: {', '.join(ctx.imports)}")
|
|
376
|
+
if ctx.comments:
|
|
377
|
+
sections.append(f"Comments: {' | '.join(ctx.comments)}")
|
|
378
|
+
if ctx.source_excerpt is not None:
|
|
379
|
+
note = " (truncated)" if ctx.source_truncated else ""
|
|
380
|
+
sections.append(f"Source{note}:\n{ctx.source_excerpt}")
|
|
381
|
+
elif ctx.read_error:
|
|
382
|
+
sections.append(f"[source unavailable: {ctx.read_error}]")
|
|
383
|
+
sections.append("")
|
|
384
|
+
|
|
385
|
+
return "\n".join(sections)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# --------------------------------------------------------------------------
|
|
389
|
+
# Step 4: tie it all together.
|
|
390
|
+
# --------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
@dataclass
|
|
393
|
+
class AskResult:
|
|
394
|
+
answer: str
|
|
395
|
+
question: str
|
|
396
|
+
keywords: list[str]
|
|
397
|
+
contexts: list[FileContext]
|
|
398
|
+
model: str
|
|
399
|
+
semantic_note: str | None = None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def ask_repository(
|
|
403
|
+
root: Path,
|
|
404
|
+
db_path: Path,
|
|
405
|
+
question: str,
|
|
406
|
+
max_files: int = 6,
|
|
407
|
+
model: str | None = None,
|
|
408
|
+
use_semantic: bool = True,
|
|
409
|
+
) -> AskResult:
|
|
410
|
+
"""
|
|
411
|
+
Run the full Stage 7 pipeline: hybrid retrieval (keyword + semantic)
|
|
412
|
+
-> collect files -> build prompt -> call the LLM -> return the
|
|
413
|
+
answer plus everything that fed it (so the CLI can optionally show
|
|
414
|
+
its work with --show-context).
|
|
415
|
+
|
|
416
|
+
Raises IndexNotFoundError if the repo hasn't been indexed
|
|
417
|
+
(propagated from retrieve_relevant_files), and LLMError/
|
|
418
|
+
LLMNotConfiguredError (from llm.py) if the model call itself can't
|
|
419
|
+
happen. A missing/unreachable *embedding* backend is NOT raised
|
|
420
|
+
here — it's reported via AskResult.semantic_note and retrieval
|
|
421
|
+
degrades to keyword-only, since `ask` should still work on a repo
|
|
422
|
+
indexed with --no-embeddings.
|
|
423
|
+
"""
|
|
424
|
+
keywords, retrieved_files, semantic_note = retrieve_relevant_files(
|
|
425
|
+
db_path, question, max_files=max_files, use_semantic=use_semantic
|
|
426
|
+
)
|
|
427
|
+
contexts = build_file_contexts(root, db_path, retrieved_files)
|
|
428
|
+
prompt = build_prompt(question, keywords, contexts)
|
|
429
|
+
|
|
430
|
+
response = call_llm(prompt, system=SYSTEM_PROMPT, model=model)
|
|
431
|
+
|
|
432
|
+
return AskResult(
|
|
433
|
+
answer=response.text,
|
|
434
|
+
question=question,
|
|
435
|
+
keywords=keywords,
|
|
436
|
+
contexts=contexts,
|
|
437
|
+
model=f'{response.provider}:{response.model}',
|
|
438
|
+
semantic_note=semantic_note,
|
|
439
|
+
)
|