pyrepowiki-cli 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.
@@ -0,0 +1,414 @@
1
+ """ZreadClient: RSC scraping + REST metadata + ask talk over httpx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import random
8
+ import ssl
9
+
10
+ import httpx
11
+
12
+ from repowiki.services.zread.flight import (
13
+ WikiInfo,
14
+ Page,
15
+ parse_wiki_data,
16
+ rewrite_callouts,
17
+ strip_frontmatter,
18
+ )
19
+
20
+ BASE = "https://zread.ai"
21
+ USER_AGENT = (
22
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
23
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
24
+ )
25
+ _TRANSIENT_STATUS = {408, 425, 429, 500, 502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 528, 529}
26
+
27
+
28
+ class ZreadError(Exception):
29
+ """Base error for Zread."""
30
+
31
+
32
+ class ZreadConnectionError(ZreadError):
33
+ """Failed to reach zread.ai."""
34
+
35
+
36
+ class ZreadNotFoundError(ZreadError):
37
+ """Repo/page not indexed or not found on zread.ai."""
38
+
39
+
40
+ class ZreadChallengeError(ZreadError):
41
+ """Cloudflare challenge or rate-limit (403/429/503)."""
42
+
43
+
44
+ def _split(repo: str) -> tuple[str, str]:
45
+ owner, _, name = repo.partition("/")
46
+ if not name:
47
+ raise ValueError(f"invalid repo: {repo!r}")
48
+ return owner, name
49
+
50
+
51
+ def _backoff(attempt: int) -> float:
52
+ return min(2 ** attempt + random.uniform(0, 1.5), 30.0)
53
+
54
+
55
+ def _is_cert_error(exc: BaseException) -> bool:
56
+ while exc is not None:
57
+ if isinstance(exc, ssl.SSLCertVerificationError):
58
+ return True
59
+ exc = exc.__cause__ or exc.__context__
60
+ return False
61
+
62
+
63
+ def _error_detail(resp: httpx.Response) -> str:
64
+ try:
65
+ data = resp.json()
66
+ except ValueError:
67
+ data = None
68
+ if isinstance(data, dict):
69
+ for key in ("msg", "detail", "message", "error"):
70
+ value = data.get(key)
71
+ if isinstance(value, str) and value.strip():
72
+ return value.strip()
73
+ return resp.text.strip()[:500]
74
+
75
+
76
+ def _unwrap(resp: httpx.Response) -> object:
77
+ try:
78
+ result = resp.json()
79
+ except json.JSONDecodeError as exc:
80
+ raise ZreadError("invalid JSON response from Zread") from exc
81
+ if not isinstance(result, dict) or result.get("code") != 0:
82
+ msg = result.get("msg") if isinstance(result, dict) else "unknown"
83
+ raise ZreadError(f"Zread API error: {msg}")
84
+ return result.get("data")
85
+
86
+
87
+ def _parse_sse_body(lines: list[str]) -> str:
88
+ event = None
89
+ finished: list[str] = []
90
+ chunks: list[str] = []
91
+ for line in lines:
92
+ line = line.strip()
93
+ if not line:
94
+ continue
95
+ if line.startswith("event:"):
96
+ event = line[6:].strip()
97
+ if event == "finish":
98
+ break
99
+ if event == "error":
100
+ raise ZreadError("talk stream returned an error event")
101
+ elif line.startswith("data:"):
102
+ try:
103
+ data = json.loads(line[5:].strip())
104
+ except json.JSONDecodeError:
105
+ continue
106
+ if event == "round_finish":
107
+ finished.append(data.get("text", ""))
108
+ elif event == "answer":
109
+ chunks.append(data.get("text", ""))
110
+ text = "\n".join(finished) or "".join(chunks)
111
+ return text.strip()
112
+
113
+
114
+ class ZreadClient:
115
+ """Stateless client; language/model/token are fixed per instance."""
116
+
117
+ def __init__(
118
+ self,
119
+ transport: httpx.AsyncBaseTransport | None = None,
120
+ lang: str = "en",
121
+ model: str = "glm-5.1",
122
+ token: str | None = None,
123
+ retries: int = 5,
124
+ ) -> None:
125
+ self._transport = transport
126
+ self._lang = lang
127
+ self._model = model
128
+ self._token = token
129
+ self._retries = retries
130
+
131
+ def _http(self, timeout: float) -> httpx.AsyncClient:
132
+ return httpx.AsyncClient(
133
+ timeout=timeout,
134
+ verify=True,
135
+ headers={"User-Agent": USER_AGENT},
136
+ cookies={"X-Locale": self._lang},
137
+ transport=self._transport,
138
+ )
139
+
140
+ def _headers(self, extra: dict | None = None) -> dict:
141
+ h = {"x-locale": self._lang}
142
+ if extra:
143
+ h.update(extra)
144
+ return h
145
+
146
+ async def _request(
147
+ self,
148
+ method: str,
149
+ url: str,
150
+ *,
151
+ timeout: float,
152
+ headers: dict | None = None,
153
+ params: dict | None = None,
154
+ json_body: dict | None = None,
155
+ ) -> httpx.Response:
156
+ async with self._http(timeout) as http:
157
+ for attempt in range(1, self._retries + 1):
158
+ try:
159
+ resp = await http.request(
160
+ method, url, headers=headers, params=params, json=json_body,
161
+ )
162
+ except httpx.TransportError as exc:
163
+ message = f"Failed to connect to Zread: {exc}"
164
+ if _is_cert_error(exc):
165
+ message += (
166
+ " (TLS certificate verification failed; set SSL_CERT_FILE "
167
+ "to your CA bundle to trust a proxy/mirror)"
168
+ )
169
+ if attempt >= self._retries or _is_cert_error(exc):
170
+ raise ZreadConnectionError(message) from exc
171
+ await asyncio.sleep(_backoff(attempt))
172
+ continue
173
+ if resp.status_code == 429 and attempt < self._retries:
174
+ wait = _backoff(attempt)
175
+ try:
176
+ wait = max(wait, float(resp.headers.get("Retry-After", "")))
177
+ except ValueError:
178
+ pass
179
+ await asyncio.sleep(wait)
180
+ continue
181
+ if resp.status_code in _TRANSIENT_STATUS and attempt < self._retries:
182
+ await asyncio.sleep(_backoff(attempt))
183
+ continue
184
+ if resp.status_code in (403, 429, 503):
185
+ raise ZreadChallengeError(f"Zread returned HTTP {resp.status_code} (Cloudflare)")
186
+ if resp.status_code == 404:
187
+ raise ZreadNotFoundError(f"Zread returned HTTP 404 for {url}")
188
+ if resp.status_code in _TRANSIENT_STATUS:
189
+ raise ZreadConnectionError(
190
+ f"Zread returned HTTP {resp.status_code} after {self._retries} attempts"
191
+ )
192
+ if not resp.is_success:
193
+ detail = _error_detail(resp)
194
+ raise ZreadError(
195
+ f"Zread returned HTTP {resp.status_code} for {url}"
196
+ + (f": {detail}" if detail else "")
197
+ )
198
+ return resp
199
+ raise ZreadError(f"Zread request failed after {self._retries} attempts")
200
+
201
+ async def repo_info(self, repo: str) -> dict:
202
+ owner, name = _split(repo)
203
+ resp = await self._request(
204
+ "GET", f"{BASE}/api/v1/repo/github/{owner}/{name}",
205
+ timeout=30.0, headers=self._headers(),
206
+ )
207
+ return _unwrap(resp) or {}
208
+
209
+ async def search_repos(self, query: str) -> list:
210
+ resp = await self._request(
211
+ "GET", f"{BASE}/api/v1/repo", timeout=30.0,
212
+ params={"q": query}, headers=self._headers(),
213
+ )
214
+ return _unwrap(resp) or []
215
+
216
+ async def trending(self) -> list:
217
+ resp = await self._request(
218
+ "GET", f"{BASE}/api/v1/public/repo/trending",
219
+ timeout=30.0, headers=self._headers(),
220
+ )
221
+ return _unwrap(resp) or []
222
+
223
+ async def recommend(self, topic: str = "") -> dict:
224
+ params = {"topic": topic} if topic else None
225
+ resp = await self._request(
226
+ "GET", f"{BASE}/api/v1/repo/recommend", timeout=30.0,
227
+ params=params, headers=self._headers(),
228
+ )
229
+ return _unwrap(resp) or {}
230
+
231
+ async def submit(self, repo: str) -> dict:
232
+ if not self._token:
233
+ raise ZreadError(
234
+ "ZREAD_TOKEN is required for submit. Get it from zread.ai after login: "
235
+ "run JSON.parse(localStorage.getItem('CGX_AUTH_STORAGE')).state.token in "
236
+ "the DevTools console, then export ZREAD_TOKEN."
237
+ )
238
+ resp = await self._request(
239
+ "POST", f"{BASE}/api/v1/public/repo/submit", timeout=30.0,
240
+ # The endpoint 400s without notification_email; upstream sends this placeholder.
241
+ json_body={"name_or_path": repo, "notification_email": "example@zread.ai"},
242
+ headers=self._headers({"Authorization": f"Bearer {self._token}"}),
243
+ )
244
+ return _unwrap(resp) or {}
245
+
246
+ async def search_wiki(self, repo: str, query: str) -> list:
247
+ info = await self.repo_info(repo)
248
+ wiki_id = str(info.get("wiki_id") or "")
249
+ if not wiki_id:
250
+ raise ZreadNotFoundError(f"no wiki_id for {repo}")
251
+ resp = await self._request(
252
+ "GET", f"{BASE}/api/v1/wiki/{wiki_id}/search", timeout=30.0,
253
+ params={"q": query}, headers=self._headers(),
254
+ )
255
+ return _unwrap(resp) or []
256
+
257
+ async def refresh(self, repo: str) -> dict:
258
+ info = await self.repo_info(repo)
259
+ repo_id = str(info.get("repo_id") or "")
260
+ if not repo_id:
261
+ raise ZreadNotFoundError(f"no repo_id for {repo}")
262
+ await self._request(
263
+ "POST", f"{BASE}/api/v1/repo/{repo_id}/refresh", timeout=30.0,
264
+ headers=self._headers(),
265
+ )
266
+ return {"repo_id": repo_id, "ok": True}
267
+
268
+ async def eta(self) -> dict:
269
+ resp = await self._request(
270
+ "GET", f"{BASE}/api/v1/repo/eta", timeout=30.0, headers=self._headers(),
271
+ )
272
+ return _unwrap(resp) or {}
273
+
274
+ async def github_head(self, repo: str) -> dict:
275
+ """Return the GitHub HEAD commit for a repo: ``{"sha": ..., "when": ...}``."""
276
+ owner, name = _split(repo)
277
+ resp = await self._request(
278
+ "GET", f"https://api.github.com/repos/{owner}/{name}/commits/HEAD",
279
+ timeout=30.0, headers=self._headers(),
280
+ )
281
+ try:
282
+ commit = resp.json()
283
+ except json.JSONDecodeError as exc:
284
+ raise ZreadError("invalid JSON response from GitHub") from exc
285
+ if not isinstance(commit, dict):
286
+ raise ZreadError("unexpected GitHub response")
287
+ committer = commit.get("commit", {}).get("committer") or {}
288
+ when = committer.get("date") if isinstance(committer, dict) else None
289
+ return {"sha": commit.get("sha") or "", "when": when}
290
+
291
+ async def read_file(
292
+ self, repo_id: str, path: str,
293
+ start: int | None = None, end: int | None = None,
294
+ ) -> str:
295
+ item = {"path": path}
296
+ if start is not None:
297
+ item["start_line"] = start
298
+ if end is not None:
299
+ item["end_line"] = end
300
+ resp = await self._request(
301
+ "POST", f"{BASE}/api/v1/repo/{repo_id}/files", timeout=30.0,
302
+ json_body={"files": [item]}, headers=self._headers(),
303
+ )
304
+ files = _unwrap(resp)
305
+ if not isinstance(files, list) or not files:
306
+ raise ZreadNotFoundError(f"file not found: {path}")
307
+ info = files[0]
308
+ snippet = info.get("snippet")
309
+ if isinstance(snippet, dict) and snippet.get("content") is not None:
310
+ return snippet["content"]
311
+ return info.get("content", "")
312
+
313
+ async def outline(self, repo: str) -> tuple[WikiInfo, list[Page]]:
314
+ wiki_id = await self._wiki_id(repo)
315
+ resp = await self._request(
316
+ "GET", f"{BASE}/api/v1/wiki/{wiki_id}", timeout=30.0, headers=self._headers(),
317
+ )
318
+ data = _unwrap(resp)
319
+ if not isinstance(data, dict):
320
+ raise ZreadNotFoundError(f"no wiki found for {repo}")
321
+ wiki_info, pages = parse_wiki_data(data)
322
+ if not pages:
323
+ raise ZreadNotFoundError(f"no wiki found for {repo}")
324
+ return wiki_info, pages
325
+
326
+ async def page(self, repo: str, slug: str | None = None) -> str:
327
+ wiki_id = await self._wiki_id(repo)
328
+ if slug is None:
329
+ _, pages = await self.outline(repo)
330
+ if not pages:
331
+ raise ZreadNotFoundError(f"no wiki pages for {repo}")
332
+ slug = pages[0].slug
333
+ resp = await self._request(
334
+ "GET", f"{BASE}/api/v1/wiki/{wiki_id}/page/{slug}", timeout=30.0,
335
+ headers=self._headers(),
336
+ )
337
+ data = _unwrap(resp)
338
+ if not isinstance(data, dict):
339
+ raise ZreadNotFoundError(f"no markdown found for {repo}/{slug}")
340
+ content = data.get("content")
341
+ if not isinstance(content, str) or not content.strip():
342
+ raise ZreadNotFoundError(f"no markdown found for {repo}/{slug}")
343
+ return rewrite_callouts(strip_frontmatter(content))
344
+
345
+ async def _wiki_id(self, repo: str) -> str:
346
+ info = await self.repo_info(repo)
347
+ wiki_id = str(info.get("wiki_id") or "")
348
+ if not wiki_id:
349
+ raise ZreadNotFoundError(f"no wiki_id for {repo}")
350
+ return wiki_id
351
+
352
+ async def ask(self, repo: str, question: str) -> str:
353
+ if not self._token:
354
+ raise ZreadError(
355
+ "ZREAD_TOKEN is required for ask. Get it from zread.ai after login: "
356
+ "run JSON.parse(localStorage.getItem('CGX_AUTH_STORAGE')).state.token in "
357
+ "the DevTools console, then export ZREAD_TOKEN."
358
+ )
359
+ info = await self.repo_info(repo)
360
+ repo_id = str(info.get("repo_id") or "")
361
+ wiki_id = str(info.get("wiki_id") or "")
362
+ if not repo_id or not wiki_id:
363
+ raise ZreadNotFoundError(f"{repo} is not indexed on zread.ai")
364
+ _, pages = await self.outline(repo)
365
+ if not pages:
366
+ raise ZreadNotFoundError(f"{repo} has no wiki pages")
367
+ talk_id = await self._create_talk()
368
+ return await self._send_message(talk_id, question, wiki_id, pages[0].page_id, repo_id)
369
+
370
+ async def _create_talk(self) -> str:
371
+ resp = await self._request(
372
+ "POST", f"{BASE}/api/v1/talk", timeout=30.0,
373
+ json_body={"query": " "},
374
+ headers=self._headers({"Authorization": f"Bearer {self._token}"}),
375
+ )
376
+ data = _unwrap(resp) or {}
377
+ talk_id = data.get("talk_id")
378
+ if not talk_id:
379
+ raise ZreadError("talk creation returned no talk_id")
380
+ return talk_id
381
+
382
+ async def _send_message(
383
+ self, talk_id: str, question: str,
384
+ wiki_id: str, page_id: str, repo_id: str,
385
+ ) -> str:
386
+ url = f"{BASE}/api/v1/talk/{talk_id}/message"
387
+ headers = self._headers({
388
+ "Authorization": f"Bearer {self._token}",
389
+ "Accept": "text/event-stream",
390
+ })
391
+ context = {"wiki": {"page_id": page_id, "wiki_id": wiki_id}}
392
+ if repo_id:
393
+ context["repo"] = {"repo_id": repo_id}
394
+ body = {
395
+ "parent_message_id": "",
396
+ "query": question,
397
+ "context": context,
398
+ "model": self._model,
399
+ }
400
+ async with self._http(timeout=120.0) as http:
401
+ async with http.stream("POST", url, headers=headers, json=body) as resp:
402
+ if resp.status_code in (403, 429, 503):
403
+ raise ZreadChallengeError(f"Zread returned HTTP {resp.status_code}")
404
+ if resp.status_code == 404:
405
+ raise ZreadNotFoundError(f"talk not found: {talk_id}")
406
+ if not resp.is_success:
407
+ await resp.aread()
408
+ detail = _error_detail(resp)
409
+ raise ZreadError(
410
+ f"Zread returned HTTP {resp.status_code} for {url}"
411
+ + (f": {detail}" if detail else "")
412
+ )
413
+ lines = [line async for line in resp.aiter_lines()]
414
+ return _parse_sse_body(lines)
@@ -0,0 +1,116 @@
1
+ """Models and pure-function transforms for zread.ai wiki data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class Page:
11
+ page_id: str
12
+ slug: str
13
+ topic: str
14
+ section: str = ""
15
+ group: str = ""
16
+ order: int = 0
17
+
18
+
19
+ @dataclass
20
+ class WikiInfo:
21
+ wiki_id: str = ""
22
+ repo_id: str = ""
23
+
24
+
25
+ _CALLOUT_RE = re.compile(r"<Cgx(?P<kind>[A-Za-z]+)>(?P<body>.*?)</Cgx(?P=kind)>", re.S)
26
+ _FENCE_OPEN_RE = re.compile(r"^\s{0,3}(```+|~~~+)")
27
+
28
+ _CALLOUT_KIND = {
29
+ "tip": "TIP",
30
+ "note": "NOTE",
31
+ "info": "NOTE",
32
+ "warn": "WARNING",
33
+ "warning": "WARNING",
34
+ "caution": "CAUTION",
35
+ "important": "CAUTION",
36
+ "danger": "CAUTION",
37
+ }
38
+
39
+
40
+ def parse_wiki_data(data: dict) -> tuple[WikiInfo, list[Page]]:
41
+ """Build ``(WikiInfo, pages)`` from the ``GET /api/v1/wiki/{wiki_id}`` body."""
42
+ info = data.get("info") if isinstance(data.get("info"), dict) else {}
43
+ wiki_info = WikiInfo(
44
+ wiki_id=str(info.get("wiki_id") or ""),
45
+ repo_id=str(info.get("repo_id") or ""),
46
+ )
47
+ pages = []
48
+ raw_pages = data.get("pages") if isinstance(data.get("pages"), list) else []
49
+ for i, p in enumerate(raw_pages):
50
+ if not isinstance(p, dict):
51
+ continue
52
+ slug = (p.get("slug") or "").strip()
53
+ if not slug:
54
+ continue
55
+ pages.append(
56
+ Page(
57
+ page_id=str(p.get("page_id") or ""),
58
+ slug=slug,
59
+ topic=(p.get("topic") or slug).strip(),
60
+ section=(p.get("section") or "").strip(),
61
+ group=(p.get("group") or "").strip(),
62
+ order=int(p.get("order", i) or 0),
63
+ )
64
+ )
65
+ pages.sort(key=lambda p: p.order)
66
+ for i, p in enumerate(pages):
67
+ p.order = i
68
+ return wiki_info, pages
69
+
70
+
71
+ def _code_spans(md: str) -> list[tuple[int, int]]:
72
+ spans = []
73
+ pos = 0
74
+ fence = None
75
+ start = 0
76
+ for line in md.splitlines(keepends=True):
77
+ stripped = line.rstrip("\n")
78
+ if fence is None:
79
+ m = _FENCE_OPEN_RE.match(stripped)
80
+ if m:
81
+ fence = m.group(1)[0] * 3
82
+ start = pos
83
+ elif stripped.strip().startswith(fence):
84
+ spans.append((start, pos + len(line)))
85
+ fence = None
86
+ pos += len(line)
87
+ if fence is not None:
88
+ spans.append((start, len(md)))
89
+ return spans
90
+
91
+
92
+ def _in_spans(idx: int, spans: list[tuple[int, int]]) -> bool:
93
+ return any(a <= idx < b for a, b in spans)
94
+
95
+
96
+ def rewrite_callouts(md: str) -> str:
97
+ """Convert ``<CgxTip>…</CgxTip>`` shells to GitHub ``> [!TIP]`` blocks."""
98
+ spans = _code_spans(md)
99
+
100
+ def sub(m: re.Match) -> str:
101
+ if _in_spans(m.start(), spans):
102
+ return m.group(0)
103
+ kind = _CALLOUT_KIND.get(m.group("kind").lower(), "NOTE")
104
+ body = m.group("body").strip()
105
+ if not body:
106
+ return ""
107
+ lines = "\n".join(f"> {ln}".rstrip() for ln in body.split("\n"))
108
+ return f"> [!{kind}]\n{lines}"
109
+
110
+ return _CALLOUT_RE.sub(sub, md)
111
+
112
+
113
+ def strip_frontmatter(md: str) -> str:
114
+ """Remove a leading ``---\n…\n---`` YAML frontmatter block."""
115
+ m = re.match(r"^---\n.*?\n---\n\n?", md, re.S)
116
+ return md[m.end():] if m else md
@@ -0,0 +1 @@
1
+ """Shared plumbing for all repowiki services."""
@@ -0,0 +1,15 @@
1
+ """Synchronous bridge for async client calls.
2
+
3
+ Named ``async_`` (not ``async``) because ``async`` is a Python keyword and a
4
+ module by that name cannot be reached with a normal ``import`` statement.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from typing import Any
11
+
12
+
13
+ def run_async(coro: Any) -> Any:
14
+ """Run an async coroutine synchronously."""
15
+ return asyncio.run(coro)
@@ -0,0 +1,36 @@
1
+ """Export wiki pages as Markdown files plus llms.txt / README.md indexes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+
9
+ def _safe_name(title: str, index: int) -> str:
10
+ """Turn a page title into a filesystem-safe ``NN-name.md`` filename."""
11
+ slug = re.sub(r"[^\w\-]+", "-", title.strip()).strip("-").lower() or "page"
12
+ return f"{index:02d}-{slug}.md"
13
+
14
+
15
+ def export_pages(out_dir: str, repo: str, pages: list[tuple[str, str]], full: str) -> int:
16
+ """Write one file per page plus ``llms.txt`` / ``README.md`` / ``llms-full.txt``.
17
+
18
+ ``pages`` is a list of ``(title, markdown)``; ``full`` is the complete
19
+ concatenated markdown. ``llms.txt`` and ``README.md`` carry the same page
20
+ index (``README.md`` for GitHub auto-rendering). Returns the page count.
21
+ """
22
+ out = Path(out_dir)
23
+ out.mkdir(parents=True, exist_ok=True)
24
+ entries: list[tuple[str, str]] = []
25
+ for i, (title, markdown) in enumerate(pages):
26
+ fname = _safe_name(title, i)
27
+ (out / fname).write_text(markdown, encoding="utf-8")
28
+ entries.append((title, fname))
29
+ index = ["# " + repo, ""]
30
+ for title, fname in entries:
31
+ index.append(f"- [{title}]({fname})")
32
+ index_text = "\n".join(index) + "\n"
33
+ (out / "llms.txt").write_text(index_text, encoding="utf-8")
34
+ (out / "README.md").write_text(index_text, encoding="utf-8")
35
+ (out / "llms-full.txt").write_text(full + "\n", encoding="utf-8")
36
+ return len(entries)
@@ -0,0 +1,29 @@
1
+ """Shared result types for wiki backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class Reference:
10
+ file_path: str
11
+ range_start: int | None = None
12
+ range_end: int | None = None
13
+
14
+
15
+ @dataclass
16
+ class SourceFile:
17
+ repo: str
18
+ path: str
19
+ content: str
20
+
21
+
22
+ @dataclass
23
+ class Answer:
24
+ body: str
25
+ summary: str | None = None
26
+ references: list[Reference] = field(default_factory=list)
27
+ sources: list[SourceFile] = field(default_factory=list)
28
+ stats: dict[str, float] = field(default_factory=dict)
29
+ query_id: str | None = None