scopedocs-cli 0.1.3__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.
- scopedocs_cli/__init__.py +12 -0
- scopedocs_cli/client.py +376 -0
- scopedocs_cli/commands/__init__.py +0 -0
- scopedocs_cli/commands/ask.py +281 -0
- scopedocs_cli/commands/auth.py +232 -0
- scopedocs_cli/commands/feedback.py +62 -0
- scopedocs_cli/commands/impact.py +73 -0
- scopedocs_cli/commands/update.py +288 -0
- scopedocs_cli/commands/why.py +107 -0
- scopedocs_cli/config.py +222 -0
- scopedocs_cli/main.py +341 -0
- scopedocs_cli/orchestrator/__init__.py +124 -0
- scopedocs_cli/orchestrator/base.py +51 -0
- scopedocs_cli/orchestrator/budget.py +60 -0
- scopedocs_cli/orchestrator/providers/__init__.py +11 -0
- scopedocs_cli/orchestrator/providers/environment.py +36 -0
- scopedocs_cli/orchestrator/providers/git.py +101 -0
- scopedocs_cli/orchestrator/providers/ide.py +49 -0
- scopedocs_cli/orchestrator/providers/workspace.py +99 -0
- scopedocs_cli/orchestrator/trace.py +208 -0
- scopedocs_cli/renderers/__init__.py +32 -0
- scopedocs_cli/renderers/json.py +14 -0
- scopedocs_cli/renderers/tui.py +250 -0
- scopedocs_cli/theme.py +111 -0
- scopedocs_cli/tui.py +898 -0
- scopedocs_cli/ui.py +272 -0
- scopedocs_cli-0.1.3.dist-info/METADATA +345 -0
- scopedocs_cli-0.1.3.dist-info/RECORD +30 -0
- scopedocs_cli-0.1.3.dist-info/WHEEL +4 -0
- scopedocs_cli-0.1.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""ScopeDocs CLI — explain services, map impact, ask questions.
|
|
2
|
+
|
|
3
|
+
Three core commands:
|
|
4
|
+
scopedocs ask "<question>" — free-form Q&A
|
|
5
|
+
scopedocs impact <target> — blast radius
|
|
6
|
+
scopedocs why <target> — governing decisions
|
|
7
|
+
|
|
8
|
+
CLI talks live to the backend at /api/v1/*. Configure with
|
|
9
|
+
`scopedocs auth login --api-key sk-sd_… --backend http://…`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.3"
|
scopedocs_cli/client.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""HTTP client wrapping the ScopeDocs backend `/api/v1/*` endpoints.
|
|
2
|
+
|
|
3
|
+
Stateless · synchronous · API-key auth via `X-API-Key` header.
|
|
4
|
+
|
|
5
|
+
Each method returns the raw JSON body. Higher-level mapping into render-ready
|
|
6
|
+
shapes happens in command modules, not here — this layer stays thin.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import platform
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from scopedocs_cli import __version__
|
|
18
|
+
from scopedocs_cli.config import Config
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ScopeDocsError(Exception):
|
|
22
|
+
"""Base error for client-side failures.
|
|
23
|
+
|
|
24
|
+
`is_actionable` distinguishes engineering bugs (worth crash-reporting) from
|
|
25
|
+
user/network conditions. Telemetry layer should only forward actionable.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
is_actionable: bool = True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthError(ScopeDocsError):
|
|
32
|
+
"""401/403 — missing or invalid API key / scope. User must fix."""
|
|
33
|
+
|
|
34
|
+
is_actionable = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NotConnectedError(ScopeDocsError):
|
|
38
|
+
"""Network failure — backend unreachable. Network/firewall, not a bug."""
|
|
39
|
+
|
|
40
|
+
is_actionable = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BackendError(ScopeDocsError):
|
|
44
|
+
"""Non-auth, non-2xx from backend. 4xx user-side, 5xx engineering bug."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, status_code: int, detail: str) -> None:
|
|
47
|
+
self.status_code = status_code
|
|
48
|
+
self.detail = detail
|
|
49
|
+
self.is_actionable = status_code >= 500
|
|
50
|
+
super().__init__(f"{status_code}: {detail}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _client_headers(api_key: Optional[str]) -> dict[str, str]:
|
|
54
|
+
"""Default headers attached to every request.
|
|
55
|
+
|
|
56
|
+
Mirrors the Warp pattern: client/version/OS metadata so the backend can
|
|
57
|
+
distinguish CLI vs Web UI vs MCP traffic in logs and metrics.
|
|
58
|
+
"""
|
|
59
|
+
headers = {
|
|
60
|
+
"User-Agent": f"scopedocs-cli/{__version__}",
|
|
61
|
+
"X-ScopeDocs-CLI-Version": __version__,
|
|
62
|
+
"X-ScopeDocs-Client": f"cli/{platform.system().lower()}",
|
|
63
|
+
}
|
|
64
|
+
if api_key:
|
|
65
|
+
headers["X-API-Key"] = api_key
|
|
66
|
+
return headers
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ScopeDocsClient:
|
|
70
|
+
"""Thin httpx wrapper. One instance per CLI invocation."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, config: Config, timeout: float = 30.0) -> None:
|
|
73
|
+
self._config = config
|
|
74
|
+
# Tighter timeout granularity: fail fast on dead connections so users
|
|
75
|
+
# see a clean NotConnectedError quickly (read still gets the full budget).
|
|
76
|
+
timeout_cfg = httpx.Timeout(connect=5.0, read=timeout, write=10.0, pool=5.0)
|
|
77
|
+
event_hooks = (
|
|
78
|
+
{"request": [self._log_request], "response": [self._log_response]}
|
|
79
|
+
if getattr(config, "verbose", False)
|
|
80
|
+
else {}
|
|
81
|
+
)
|
|
82
|
+
self._client = httpx.Client(
|
|
83
|
+
base_url=config.backend_url.rstrip("/"),
|
|
84
|
+
headers=_client_headers(config.api_key),
|
|
85
|
+
timeout=timeout_cfg,
|
|
86
|
+
event_hooks=event_hooks,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
# Verbose-mode hooks (Warp-style request/response tracing)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _log_request(request: httpx.Request) -> None:
|
|
94
|
+
request.extensions["sd_started_at"] = time.monotonic()
|
|
95
|
+
print(
|
|
96
|
+
f"→ {request.method} {request.url}",
|
|
97
|
+
file=sys.stderr,
|
|
98
|
+
flush=True,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _log_response(response: httpx.Response) -> None:
|
|
103
|
+
started = response.request.extensions.get("sd_started_at")
|
|
104
|
+
elapsed_ms = (time.monotonic() - started) * 1000 if started else 0.0
|
|
105
|
+
print(
|
|
106
|
+
f"← {response.status_code} {response.request.url} "
|
|
107
|
+
f"in {elapsed_ms:.0f}ms",
|
|
108
|
+
file=sys.stderr,
|
|
109
|
+
flush=True,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# Lifecycle
|
|
114
|
+
|
|
115
|
+
def close(self) -> None:
|
|
116
|
+
self._client.close()
|
|
117
|
+
|
|
118
|
+
def __enter__(self) -> "ScopeDocsClient":
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def __exit__(self, *exc: Any) -> None:
|
|
122
|
+
self.close()
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# Internal request helper
|
|
126
|
+
|
|
127
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
128
|
+
if not self._config.api_key:
|
|
129
|
+
raise AuthError(
|
|
130
|
+
"No API key configured. Run `scopedocs auth login` "
|
|
131
|
+
"or set SCOPEDOCS_API_KEY."
|
|
132
|
+
)
|
|
133
|
+
try:
|
|
134
|
+
response = self._client.request(method, path, **kwargs)
|
|
135
|
+
except httpx.ConnectError as exc:
|
|
136
|
+
raise NotConnectedError(
|
|
137
|
+
f"Cannot reach backend at {self._config.backend_url}. "
|
|
138
|
+
f"Is it running? ({exc})"
|
|
139
|
+
) from exc
|
|
140
|
+
except httpx.TimeoutException as exc:
|
|
141
|
+
raise NotConnectedError(f"Request timed out: {exc}") from exc
|
|
142
|
+
|
|
143
|
+
if response.status_code in (401, 403):
|
|
144
|
+
raise AuthError(
|
|
145
|
+
f"{response.status_code}: {_extract_detail(response)}"
|
|
146
|
+
)
|
|
147
|
+
if response.status_code >= 400:
|
|
148
|
+
raise BackendError(response.status_code, _extract_detail(response))
|
|
149
|
+
|
|
150
|
+
if response.headers.get("content-type", "").startswith("application/json"):
|
|
151
|
+
return response.json()
|
|
152
|
+
return response.text
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# API methods (matches backend/api/v1.py)
|
|
156
|
+
|
|
157
|
+
def chat(
|
|
158
|
+
self,
|
|
159
|
+
message: str,
|
|
160
|
+
*,
|
|
161
|
+
type_: str = "documentation",
|
|
162
|
+
repo: Optional[str] = None,
|
|
163
|
+
query_context: Optional[dict] = None,
|
|
164
|
+
client_trace_id: Optional[str] = None,
|
|
165
|
+
budget_tokens: Optional[int] = None,
|
|
166
|
+
use_session_history: bool = False,
|
|
167
|
+
) -> dict:
|
|
168
|
+
"""POST /api/v1/chat — non-streaming JSON response."""
|
|
169
|
+
payload = self._chat_payload(
|
|
170
|
+
message,
|
|
171
|
+
type_=type_,
|
|
172
|
+
repo=repo,
|
|
173
|
+
query_context=query_context,
|
|
174
|
+
client_trace_id=client_trace_id,
|
|
175
|
+
budget_tokens=budget_tokens,
|
|
176
|
+
use_session_history=use_session_history,
|
|
177
|
+
stream=False,
|
|
178
|
+
)
|
|
179
|
+
return self._request("POST", "/api/v1/chat", json=payload)
|
|
180
|
+
|
|
181
|
+
def chat_stream(
|
|
182
|
+
self,
|
|
183
|
+
message: str,
|
|
184
|
+
*,
|
|
185
|
+
type_: str = "documentation",
|
|
186
|
+
repo: Optional[str] = None,
|
|
187
|
+
query_context: Optional[dict] = None,
|
|
188
|
+
client_trace_id: Optional[str] = None,
|
|
189
|
+
budget_tokens: Optional[int] = None,
|
|
190
|
+
use_session_history: bool = False,
|
|
191
|
+
):
|
|
192
|
+
"""POST /api/v1/chat with stream=True — yield SSE events as dicts.
|
|
193
|
+
|
|
194
|
+
Each yielded item is a parsed `data:` JSON object:
|
|
195
|
+
{"type": "meta", "sources": [...], "backend_request_id": ...}
|
|
196
|
+
{"type": "chunk", "delta": "..."}
|
|
197
|
+
{"type": "done", "answer": "...", "model": "...", ...}
|
|
198
|
+
{"type": "error", "detail": "..."}
|
|
199
|
+
"""
|
|
200
|
+
if not self._config.api_key:
|
|
201
|
+
raise AuthError(
|
|
202
|
+
"No API key configured. Run `scopedocs auth login` "
|
|
203
|
+
"or set SCOPEDOCS_API_KEY."
|
|
204
|
+
)
|
|
205
|
+
payload = self._chat_payload(
|
|
206
|
+
message,
|
|
207
|
+
type_=type_,
|
|
208
|
+
repo=repo,
|
|
209
|
+
query_context=query_context,
|
|
210
|
+
client_trace_id=client_trace_id,
|
|
211
|
+
budget_tokens=budget_tokens,
|
|
212
|
+
use_session_history=use_session_history,
|
|
213
|
+
stream=True,
|
|
214
|
+
)
|
|
215
|
+
try:
|
|
216
|
+
with self._client.stream(
|
|
217
|
+
"POST", "/api/v1/chat", json=payload
|
|
218
|
+
) as resp:
|
|
219
|
+
if resp.status_code in (401, 403):
|
|
220
|
+
raise AuthError(f"{resp.status_code}: auth failed")
|
|
221
|
+
if resp.status_code >= 400:
|
|
222
|
+
raise BackendError(resp.status_code, "stream rejected")
|
|
223
|
+
for line in resp.iter_lines():
|
|
224
|
+
if not line:
|
|
225
|
+
continue
|
|
226
|
+
if line.startswith("data: "):
|
|
227
|
+
chunk = line[6:]
|
|
228
|
+
elif line.startswith("data:"):
|
|
229
|
+
chunk = line[5:]
|
|
230
|
+
else:
|
|
231
|
+
continue
|
|
232
|
+
chunk = chunk.strip()
|
|
233
|
+
if not chunk:
|
|
234
|
+
continue
|
|
235
|
+
try:
|
|
236
|
+
import json as _json
|
|
237
|
+
yield _json.loads(chunk)
|
|
238
|
+
except ValueError:
|
|
239
|
+
continue
|
|
240
|
+
except httpx.ConnectError as exc:
|
|
241
|
+
raise NotConnectedError(
|
|
242
|
+
f"Cannot reach backend at {self._config.backend_url}. ({exc})"
|
|
243
|
+
) from exc
|
|
244
|
+
except httpx.TimeoutException as exc:
|
|
245
|
+
raise NotConnectedError(f"Stream timed out: {exc}") from exc
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _chat_payload(
|
|
249
|
+
message: str,
|
|
250
|
+
*,
|
|
251
|
+
type_: str,
|
|
252
|
+
repo: Optional[str],
|
|
253
|
+
query_context: Optional[dict],
|
|
254
|
+
client_trace_id: Optional[str],
|
|
255
|
+
budget_tokens: Optional[int],
|
|
256
|
+
use_session_history: bool,
|
|
257
|
+
stream: bool,
|
|
258
|
+
) -> dict[str, Any]:
|
|
259
|
+
payload: dict[str, Any] = {"message": message, "type": type_}
|
|
260
|
+
if repo:
|
|
261
|
+
payload["repo"] = repo
|
|
262
|
+
if query_context:
|
|
263
|
+
payload["query_context"] = query_context
|
|
264
|
+
if client_trace_id:
|
|
265
|
+
payload["client_trace_id"] = client_trace_id
|
|
266
|
+
if budget_tokens is not None:
|
|
267
|
+
payload["budget_tokens"] = budget_tokens
|
|
268
|
+
if use_session_history:
|
|
269
|
+
payload["use_session_history"] = True
|
|
270
|
+
if stream:
|
|
271
|
+
payload["stream"] = True
|
|
272
|
+
return payload
|
|
273
|
+
|
|
274
|
+
def feedback(
|
|
275
|
+
self,
|
|
276
|
+
*,
|
|
277
|
+
trace_id: str,
|
|
278
|
+
rating: str,
|
|
279
|
+
note: Optional[str] = None,
|
|
280
|
+
question: Optional[str] = None,
|
|
281
|
+
backend_request_id: Optional[str] = None,
|
|
282
|
+
) -> dict:
|
|
283
|
+
"""POST /api/v1/feedback — labelled training signal for DSPy.
|
|
284
|
+
|
|
285
|
+
rating ∈ {"good", "bad"}. The backend may store this against the
|
|
286
|
+
original `query_context` recorded at chat time.
|
|
287
|
+
"""
|
|
288
|
+
payload: dict[str, Any] = {"trace_id": trace_id, "rating": rating}
|
|
289
|
+
if note:
|
|
290
|
+
payload["note"] = note
|
|
291
|
+
if question:
|
|
292
|
+
payload["question"] = question
|
|
293
|
+
if backend_request_id:
|
|
294
|
+
payload["backend_request_id"] = backend_request_id
|
|
295
|
+
return self._request("POST", "/api/v1/feedback", json=payload)
|
|
296
|
+
|
|
297
|
+
def search_docs(self, query: str, top_k: int = 5) -> dict:
|
|
298
|
+
"""GET /api/v1/docs/search — vector search over generated docs."""
|
|
299
|
+
return self._request(
|
|
300
|
+
"GET",
|
|
301
|
+
"/api/v1/docs/search",
|
|
302
|
+
params={"q": query, "top_k": top_k},
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def get_doc(self, doc_id: str) -> dict:
|
|
306
|
+
"""GET /api/v1/docs/{doc_id} — fetch single doc."""
|
|
307
|
+
return self._request("GET", f"/api/v1/docs/{doc_id}")
|
|
308
|
+
|
|
309
|
+
def list_docs(
|
|
310
|
+
self,
|
|
311
|
+
*,
|
|
312
|
+
repo: Optional[str] = None,
|
|
313
|
+
limit: int = 50,
|
|
314
|
+
offset: int = 0,
|
|
315
|
+
) -> dict:
|
|
316
|
+
"""GET /api/v1/docs — list docs with pagination."""
|
|
317
|
+
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
|
318
|
+
if repo:
|
|
319
|
+
params["repo"] = repo
|
|
320
|
+
return self._request("GET", "/api/v1/docs", params=params)
|
|
321
|
+
|
|
322
|
+
def get_graph(self, repo: str) -> dict:
|
|
323
|
+
"""GET /api/v1/graph/{repo} — full graph for a repo."""
|
|
324
|
+
return self._request("GET", f"/api/v1/graph/{repo}")
|
|
325
|
+
|
|
326
|
+
def get_graph_node(self, node_id: str) -> dict:
|
|
327
|
+
"""GET /api/v1/graph/node/{node_id} — single node detail."""
|
|
328
|
+
return self._request("GET", f"/api/v1/graph/node/{node_id}")
|
|
329
|
+
|
|
330
|
+
def get_pr(self, repo: str, number: int) -> dict:
|
|
331
|
+
"""GET /api/v1/prs/{repo}/{number} — PR detail."""
|
|
332
|
+
return self._request("GET", f"/api/v1/prs/{repo}/{number}")
|
|
333
|
+
|
|
334
|
+
def workspace(self) -> dict:
|
|
335
|
+
"""GET /api/v1/workspace — workspaces accessible to this API key."""
|
|
336
|
+
return self._request("GET", "/api/v1/workspace")
|
|
337
|
+
|
|
338
|
+
def me(self) -> dict:
|
|
339
|
+
"""GET /api/v1/me — identity attached to this API key.
|
|
340
|
+
|
|
341
|
+
Returns key metadata (name, prefix, scopes, rate-limit, dates) plus
|
|
342
|
+
org id/name and the caller's effective workspace_id. Used by
|
|
343
|
+
`scopedocs auth status` for a richer "who am I" report.
|
|
344
|
+
"""
|
|
345
|
+
return self._request("GET", "/api/v1/me")
|
|
346
|
+
|
|
347
|
+
def impact(self, target: str, *, since_days: int = 90, budget_tokens: int = 3000) -> dict:
|
|
348
|
+
"""GET /api/v1/impact/{target} — blast-radius analysis.
|
|
349
|
+
|
|
350
|
+
NOTE: Endpoint may not yet be deployed; raises BackendError(404) in that case.
|
|
351
|
+
"""
|
|
352
|
+
from urllib.parse import quote
|
|
353
|
+
return self._request(
|
|
354
|
+
"GET",
|
|
355
|
+
f"/api/v1/impact/{quote(target, safe='')}",
|
|
356
|
+
params={"since_days": since_days, "budget_tokens": budget_tokens},
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _extract_detail(response: httpx.Response) -> str:
|
|
361
|
+
try:
|
|
362
|
+
body = response.json()
|
|
363
|
+
except Exception:
|
|
364
|
+
return response.text or "(empty body)"
|
|
365
|
+
if isinstance(body, dict):
|
|
366
|
+
return str(body.get("detail") or body.get("message") or body)
|
|
367
|
+
return str(body)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
__all__ = [
|
|
371
|
+
"ScopeDocsClient",
|
|
372
|
+
"ScopeDocsError",
|
|
373
|
+
"AuthError",
|
|
374
|
+
"NotConnectedError",
|
|
375
|
+
"BackendError",
|
|
376
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""`scopedocs ask "<question>"` — free-form Q&A over the knowledge graph.
|
|
2
|
+
|
|
3
|
+
Wires `POST /api/v1/chat` ([backend/api/v1.py:369](../../../backend/api/v1.py)).
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from scopedocs_cli import ui
|
|
10
|
+
from scopedocs_cli.client import (
|
|
11
|
+
AuthError,
|
|
12
|
+
BackendError,
|
|
13
|
+
NotConnectedError,
|
|
14
|
+
ScopeDocsClient,
|
|
15
|
+
)
|
|
16
|
+
from scopedocs_cli.config import Config
|
|
17
|
+
from scopedocs_cli.orchestrator import complete_trace, start_trace
|
|
18
|
+
from scopedocs_cli.renderers import render
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(question: str, config: Config) -> None:
|
|
22
|
+
payload = _resolve(question, config)
|
|
23
|
+
render("ask", payload, config)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def _resolve(question: str, config: Config) -> dict[str, Any]:
|
|
29
|
+
trace, query_context = start_trace("ask", question, config)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
with ScopeDocsClient(config) as client:
|
|
33
|
+
if config.stream:
|
|
34
|
+
response = _consume_stream(
|
|
35
|
+
client.chat_stream(
|
|
36
|
+
question,
|
|
37
|
+
type_="documentation",
|
|
38
|
+
query_context=query_context,
|
|
39
|
+
client_trace_id=trace.trace_id,
|
|
40
|
+
budget_tokens=config.budget,
|
|
41
|
+
use_session_history=config.session,
|
|
42
|
+
),
|
|
43
|
+
quiet=config.quiet,
|
|
44
|
+
)
|
|
45
|
+
else:
|
|
46
|
+
with ui.orchestration_trace("Answering"):
|
|
47
|
+
response = client.chat(
|
|
48
|
+
question,
|
|
49
|
+
type_="documentation",
|
|
50
|
+
query_context=query_context,
|
|
51
|
+
client_trace_id=trace.trace_id,
|
|
52
|
+
budget_tokens=config.budget,
|
|
53
|
+
use_session_history=config.session,
|
|
54
|
+
)
|
|
55
|
+
result = _from_chat_response(question, response)
|
|
56
|
+
complete_trace(trace, result, config)
|
|
57
|
+
return result
|
|
58
|
+
except (AuthError, NotConnectedError, BackendError) as exc:
|
|
59
|
+
if not config.quiet:
|
|
60
|
+
ui.console.print(
|
|
61
|
+
f" [{ui.STYLE['marker_warn']}]{type(exc).__name__}[/] "
|
|
62
|
+
f"[{ui.STYLE['body_dim']}]{exc}[/]"
|
|
63
|
+
)
|
|
64
|
+
raise
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _consume_stream(events, *, quiet: bool) -> dict[str, Any]:
|
|
68
|
+
"""Drain SSE events, render token-by-token, return assembled response.
|
|
69
|
+
|
|
70
|
+
On the first `meta` event we already have sources — render the citation
|
|
71
|
+
chips immediately. On `chunk` we stream into stderr (so `--json` piping
|
|
72
|
+
doesn't intermix). On `done` we produce the final response dict.
|
|
73
|
+
"""
|
|
74
|
+
import sys
|
|
75
|
+
|
|
76
|
+
chunks: list[str] = []
|
|
77
|
+
sources: list = []
|
|
78
|
+
meta: dict[str, Any] = {}
|
|
79
|
+
done: dict[str, Any] = {}
|
|
80
|
+
|
|
81
|
+
for event in events:
|
|
82
|
+
kind = event.get("type")
|
|
83
|
+
if kind == "meta":
|
|
84
|
+
meta = event
|
|
85
|
+
sources = event.get("sources") or []
|
|
86
|
+
if not quiet:
|
|
87
|
+
print(
|
|
88
|
+
f" ↪ {len(sources)} source(s) · req={event.get('backend_request_id', '')[:8]}",
|
|
89
|
+
file=sys.stderr,
|
|
90
|
+
flush=True,
|
|
91
|
+
)
|
|
92
|
+
elif kind == "chunk":
|
|
93
|
+
delta = event.get("delta", "")
|
|
94
|
+
chunks.append(delta)
|
|
95
|
+
if not quiet:
|
|
96
|
+
# Print tokens to stdout so the user sees progress live.
|
|
97
|
+
# Renderer will re-print the assembled answer afterwards;
|
|
98
|
+
# the redundant render is fine in TTY mode and stripped
|
|
99
|
+
# automatically when piped (renderer is the source of truth
|
|
100
|
+
# for `--json`).
|
|
101
|
+
pass
|
|
102
|
+
elif kind == "done":
|
|
103
|
+
done = event
|
|
104
|
+
elif kind == "error":
|
|
105
|
+
raise BackendError(500, event.get("detail", "stream error"))
|
|
106
|
+
|
|
107
|
+
answer = done.get("answer") or "".join(chunks)
|
|
108
|
+
return {
|
|
109
|
+
"answer": answer,
|
|
110
|
+
"sources": sources,
|
|
111
|
+
"db_result": None,
|
|
112
|
+
"backend_request_id": meta.get("backend_request_id"),
|
|
113
|
+
"client_trace_id": meta.get("client_trace_id"),
|
|
114
|
+
"model": done.get("model"),
|
|
115
|
+
"tokens_used": done.get("tokens_used"),
|
|
116
|
+
"latency_ms": done.get("latency_ms"),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _from_chat_response(question: str, resp: dict[str, Any]) -> dict[str, Any]:
|
|
121
|
+
"""Map backend `/api/v1/chat` response → ask schema.
|
|
122
|
+
|
|
123
|
+
Backend returns two source shapes (mirroring the web frontend's
|
|
124
|
+
`SourceReference` discriminated union — see frontend-next/src/lib/api.ts):
|
|
125
|
+
- **code**: {type:'code', file_path, repo_full_name, start_line,
|
|
126
|
+
end_line, function_name, similarity, ref}
|
|
127
|
+
- **context**: {type: linear_issue|slack_message|workspace_document|
|
|
128
|
+
notion_page|jira_issue|github_pr|generated_doc,
|
|
129
|
+
source_id, title, url, author, content, similarity, ref}
|
|
130
|
+
"""
|
|
131
|
+
answer = resp.get("answer", "") or ""
|
|
132
|
+
raw_sources = resp.get("sources", []) or []
|
|
133
|
+
|
|
134
|
+
sources: list[dict[str, Any]] = []
|
|
135
|
+
evidence: list[dict[str, Any]] = []
|
|
136
|
+
for idx, raw in enumerate(raw_sources, start=1):
|
|
137
|
+
# Strip brackets from the backend ref ("[1]" → "1") so the renderer
|
|
138
|
+
# owns formatting decisions.
|
|
139
|
+
ref = str(raw.get("ref") or f"{idx}").strip("[]")
|
|
140
|
+
kind = str(raw.get("type") or "unknown")
|
|
141
|
+
|
|
142
|
+
if kind == "code":
|
|
143
|
+
repo = raw.get("repo_full_name") or raw.get("repo") or ""
|
|
144
|
+
path = raw.get("file_path") or ""
|
|
145
|
+
ls, le = raw.get("start_line"), raw.get("end_line")
|
|
146
|
+
line_range = (
|
|
147
|
+
f":{ls}-{le}" if ls and le else (f":{ls}" if ls else "")
|
|
148
|
+
)
|
|
149
|
+
full_title = f"{repo}/{path}" if repo else path or f"source-{idx}"
|
|
150
|
+
fn_name = raw.get("function_name")
|
|
151
|
+
# Source col: file:line (compact). Signal col: function name if
|
|
152
|
+
# available, else short-form repo (last segment) so user can
|
|
153
|
+
# distinguish multi-repo queries.
|
|
154
|
+
short_repo = repo.split("/")[-1] if "/" in repo else repo
|
|
155
|
+
signal = fn_name or (f"in {short_repo}" if short_repo else "")
|
|
156
|
+
url = raw.get("url") or ""
|
|
157
|
+
sources.append(
|
|
158
|
+
{
|
|
159
|
+
"ref": ref,
|
|
160
|
+
"url": url,
|
|
161
|
+
"type": "code",
|
|
162
|
+
"title": full_title,
|
|
163
|
+
"file_path": path,
|
|
164
|
+
"repo": repo,
|
|
165
|
+
"start_line": ls,
|
|
166
|
+
"end_line": le,
|
|
167
|
+
"function_name": fn_name,
|
|
168
|
+
"similarity": raw.get("similarity"),
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
evidence.append(
|
|
172
|
+
{
|
|
173
|
+
"source": _truncate(f"{path}{line_range}", 60),
|
|
174
|
+
"system": "code",
|
|
175
|
+
"signal": _truncate(str(signal), 80),
|
|
176
|
+
"ref": ref,
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
title = (
|
|
181
|
+
raw.get("title")
|
|
182
|
+
or raw.get("name")
|
|
183
|
+
or raw.get("file_path")
|
|
184
|
+
or raw.get("path")
|
|
185
|
+
or f"source-{idx}"
|
|
186
|
+
)
|
|
187
|
+
url = raw.get("url") or raw.get("link") or ""
|
|
188
|
+
# `content` is the full body for linear/slack/notion/etc.
|
|
189
|
+
# `excerpt`/`snippet`/`summary` are legacy aliases.
|
|
190
|
+
content = (
|
|
191
|
+
raw.get("content")
|
|
192
|
+
or raw.get("excerpt")
|
|
193
|
+
or raw.get("snippet")
|
|
194
|
+
or raw.get("summary")
|
|
195
|
+
or ""
|
|
196
|
+
)
|
|
197
|
+
# Pull a one-line signal: first non-empty line after stripping
|
|
198
|
+
# markdown headers / "Title:" lead-ins.
|
|
199
|
+
signal = _first_meaningful_line(content)
|
|
200
|
+
author = raw.get("author")
|
|
201
|
+
sources.append(
|
|
202
|
+
{
|
|
203
|
+
"ref": ref,
|
|
204
|
+
"url": url,
|
|
205
|
+
"type": kind,
|
|
206
|
+
"title": title,
|
|
207
|
+
"author": author,
|
|
208
|
+
"similarity": raw.get("similarity"),
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
evidence.append(
|
|
212
|
+
{
|
|
213
|
+
"source": _truncate(str(title), 60),
|
|
214
|
+
"system": kind,
|
|
215
|
+
"signal": _truncate(signal, 80),
|
|
216
|
+
"ref": ref,
|
|
217
|
+
**({"author": author} if author else {}),
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
summary = answer
|
|
222
|
+
recommendation = ""
|
|
223
|
+
# Heuristic: if the answer ends with a "Recommendation:" / "Next:" tail,
|
|
224
|
+
# split it off so the TUI can render it as its own section. Cheap and
|
|
225
|
+
# robust enough for v1 — backend doesn't structure it explicitly yet.
|
|
226
|
+
for marker in ("\nRecommendation:", "\nNext steps:", "\nNext:"):
|
|
227
|
+
if marker in answer:
|
|
228
|
+
head, tail = answer.rsplit(marker, 1)
|
|
229
|
+
summary = head.rstrip()
|
|
230
|
+
recommendation = tail.strip()
|
|
231
|
+
break
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
"command": "ask",
|
|
235
|
+
"question": question,
|
|
236
|
+
"summary": summary,
|
|
237
|
+
"evidence": evidence,
|
|
238
|
+
"recommendation": recommendation,
|
|
239
|
+
"sources": sources,
|
|
240
|
+
"tokens_used": resp.get("tokens_used", 0) or 0,
|
|
241
|
+
"tokens_budget": 0,
|
|
242
|
+
"db_result": resp.get("db_result"),
|
|
243
|
+
"backend_request_id": resp.get("backend_request_id"),
|
|
244
|
+
"model": resp.get("model"),
|
|
245
|
+
"latency_ms": resp.get("latency_ms"),
|
|
246
|
+
"cached": resp.get("cached", False),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _truncate(s: str, n: int) -> str:
|
|
251
|
+
s = s.replace("\n", " ").strip()
|
|
252
|
+
return s if len(s) <= n else s[: n - 1] + "…"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _first_meaningful_line(content: str) -> str:
|
|
256
|
+
"""Pluck a 1-line signal out of a Linear / Slack / generated_doc body.
|
|
257
|
+
|
|
258
|
+
The backend's `format_for_llm` packs the body as:
|
|
259
|
+
Title: ...
|
|
260
|
+
Status: ...
|
|
261
|
+
Description: <real signal>
|
|
262
|
+
We skip those lead-in lines plus markdown headers, take the first line
|
|
263
|
+
that actually says something.
|
|
264
|
+
"""
|
|
265
|
+
if not content:
|
|
266
|
+
return ""
|
|
267
|
+
skip_prefixes = ("title:", "status:", "team:", "priority:", "assignee:", "#")
|
|
268
|
+
for raw_line in content.splitlines():
|
|
269
|
+
line = raw_line.strip()
|
|
270
|
+
if not line:
|
|
271
|
+
continue
|
|
272
|
+
low = line.lower()
|
|
273
|
+
if low.startswith(skip_prefixes) or low.startswith("description:"):
|
|
274
|
+
# If 'Description:' has tail on same line, use the tail.
|
|
275
|
+
if low.startswith("description:"):
|
|
276
|
+
tail = line.split(":", 1)[1].strip()
|
|
277
|
+
if tail:
|
|
278
|
+
return tail
|
|
279
|
+
continue
|
|
280
|
+
return line
|
|
281
|
+
return content.strip().splitlines()[0] if content.strip() else ""
|