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 ADDED
@@ -0,0 +1,3 @@
1
+ """Seschat — a repository intelligence tool."""
2
+
3
+ __version__ = "0.1.0"
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
+ )