devmemory-cli 0.1.0.dev0__py3-none-any.whl → 0.1.0.dev1__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.
- devmemory/__about__.py +1 -1
- devmemory/adapters/genie.py +161 -0
- devmemory/api/app.py +27 -0
- devmemory/api/schemas.py +15 -0
- devmemory/config.py +5 -0
- devmemory/web/static/assets/{index-CbV5njRH.js → index-B9obAJEW.js} +20 -15
- devmemory/web/static/assets/{index-DD-7ceZx.css → index-C81gevra.css} +1 -1
- devmemory/web/static/index.html +2 -2
- {devmemory_cli-0.1.0.dev0.dist-info → devmemory_cli-0.1.0.dev1.dist-info}/METADATA +1 -1
- {devmemory_cli-0.1.0.dev0.dist-info → devmemory_cli-0.1.0.dev1.dist-info}/RECORD +13 -12
- {devmemory_cli-0.1.0.dev0.dist-info → devmemory_cli-0.1.0.dev1.dist-info}/WHEEL +0 -0
- {devmemory_cli-0.1.0.dev0.dist-info → devmemory_cli-0.1.0.dev1.dist-info}/entry_points.txt +0 -0
- {devmemory_cli-0.1.0.dev0.dist-info → devmemory_cli-0.1.0.dev1.dist-info}/licenses/LICENSE +0 -0
devmemory/__about__.py
CHANGED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Databricks Genie - a natural-language chat layer over the published telemetry.
|
|
2
|
+
|
|
3
|
+
Genie answers questions in plain English against a *Genie space* that sits on
|
|
4
|
+
top of the ``devmemory.analytics`` Delta tables (see
|
|
5
|
+
:mod:`devmemory.adapters.databricks`). This adapter is a thin wrapper over the
|
|
6
|
+
Databricks SDK's Genie Conversation API: one call per turn, returning the
|
|
7
|
+
answer text, the SQL Genie ran, and a small preview of the rows.
|
|
8
|
+
|
|
9
|
+
Everything degrades gracefully (§21): with no credentials, no ``databricks``
|
|
10
|
+
extra, or no space id, :attr:`GenieAdapter.is_configured` is ``False`` and the
|
|
11
|
+
dashboard simply hides the chat.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
from devmemory.config import DevMemoryConfig, resolve_databricks_credentials
|
|
22
|
+
from devmemory.domain.errors import DatabricksError
|
|
23
|
+
from devmemory.logging import get_logger
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from databricks.sdk import WorkspaceClient
|
|
27
|
+
|
|
28
|
+
_log = get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
_MAX_ROWS = 50
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GenieUnavailableError(DatabricksError):
|
|
34
|
+
"""Genie is not configured, the SDK is missing, or the space is unreachable."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GenieAnswer(BaseModel):
|
|
38
|
+
conversation_id: str | None = None
|
|
39
|
+
message_id: str | None = None
|
|
40
|
+
question: str
|
|
41
|
+
text: str | None = None
|
|
42
|
+
sql: str | None = None
|
|
43
|
+
sql_description: str | None = None
|
|
44
|
+
columns: list[str] = []
|
|
45
|
+
rows: list[list[Any]] = []
|
|
46
|
+
row_count: int | None = None
|
|
47
|
+
truncated: bool = False
|
|
48
|
+
error: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class GenieAdapter:
|
|
52
|
+
def __init__(self, config: DevMemoryConfig) -> None:
|
|
53
|
+
self._config = config
|
|
54
|
+
self._space_id = (
|
|
55
|
+
os.environ.get("DATABRICKS_GENIE_SPACE_ID") or config.databricks.genie_space_id or ""
|
|
56
|
+
).strip()
|
|
57
|
+
self._client: WorkspaceClient | None = None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def space_id(self) -> str:
|
|
61
|
+
return self._space_id
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_configured(self) -> bool:
|
|
65
|
+
return bool(self._space_id) and resolve_databricks_credentials() is not None
|
|
66
|
+
|
|
67
|
+
def unavailable_reason(self) -> str | None:
|
|
68
|
+
if not self._space_id:
|
|
69
|
+
return "no Genie space configured (set databricks.genie_space_id or DATABRICKS_GENIE_SPACE_ID)"
|
|
70
|
+
if resolve_databricks_credentials() is None:
|
|
71
|
+
return "Databricks credentials are not set (DATABRICKS_HOST / DATABRICKS_TOKEN)"
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# -- connection ---------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def _workspace(self) -> WorkspaceClient:
|
|
77
|
+
if self._client is not None:
|
|
78
|
+
return self._client
|
|
79
|
+
creds = resolve_databricks_credentials()
|
|
80
|
+
if creds is None:
|
|
81
|
+
raise GenieUnavailableError("Databricks credentials are not set.")
|
|
82
|
+
try:
|
|
83
|
+
from databricks.sdk import WorkspaceClient
|
|
84
|
+
except ImportError as exc: # pragma: no cover - extra not installed
|
|
85
|
+
raise GenieUnavailableError(
|
|
86
|
+
"the databricks extra is not installed",
|
|
87
|
+
hint="pip install 'devmemory-cli[databricks]'",
|
|
88
|
+
) from exc
|
|
89
|
+
self._client = WorkspaceClient(host=creds.host, token=creds.token)
|
|
90
|
+
return self._client
|
|
91
|
+
|
|
92
|
+
# -- one chat turn ----------------------------------------------
|
|
93
|
+
|
|
94
|
+
def ask(self, question: str, *, conversation_id: str | None = None) -> GenieAnswer:
|
|
95
|
+
"""Send one question and wait for the answer. Pass ``conversation_id`` to
|
|
96
|
+
continue an existing thread (Genie keeps the earlier context)."""
|
|
97
|
+
if not self._space_id:
|
|
98
|
+
raise GenieUnavailableError("no Genie space configured")
|
|
99
|
+
w = self._workspace()
|
|
100
|
+
q = question.strip()
|
|
101
|
+
try:
|
|
102
|
+
if conversation_id:
|
|
103
|
+
msg = w.genie.create_message_and_wait(self._space_id, conversation_id, q)
|
|
104
|
+
else:
|
|
105
|
+
msg = w.genie.start_conversation_and_wait(self._space_id, q)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
raise GenieUnavailableError(f"Genie request failed: {exc}") from exc
|
|
108
|
+
|
|
109
|
+
answer = GenieAnswer(
|
|
110
|
+
question=q,
|
|
111
|
+
conversation_id=msg.conversation_id,
|
|
112
|
+
message_id=msg.message_id,
|
|
113
|
+
error=msg.error.error if msg.error is not None else None,
|
|
114
|
+
)
|
|
115
|
+
self._fill_from_attachments(w, msg, answer)
|
|
116
|
+
_log.info(
|
|
117
|
+
"genie.answer",
|
|
118
|
+
space=self._space_id,
|
|
119
|
+
conversation=answer.conversation_id,
|
|
120
|
+
has_sql=answer.sql is not None,
|
|
121
|
+
rows=len(answer.rows),
|
|
122
|
+
)
|
|
123
|
+
return answer
|
|
124
|
+
|
|
125
|
+
def _fill_from_attachments(
|
|
126
|
+
self, w: WorkspaceClient, msg: Any, answer: GenieAnswer
|
|
127
|
+
) -> None:
|
|
128
|
+
for att in msg.attachments or []:
|
|
129
|
+
if getattr(att, "text", None) and att.text.content:
|
|
130
|
+
answer.text = (answer.text or "") + att.text.content
|
|
131
|
+
query = getattr(att, "query", None)
|
|
132
|
+
if query is None:
|
|
133
|
+
continue
|
|
134
|
+
answer.sql = query.query
|
|
135
|
+
answer.sql_description = query.description
|
|
136
|
+
if not answer.text and query.description:
|
|
137
|
+
answer.text = query.description
|
|
138
|
+
try:
|
|
139
|
+
self._attach_rows(w, msg, att.attachment_id, answer)
|
|
140
|
+
except Exception as exc: # results are best-effort
|
|
141
|
+
_log.warning("genie.results_failed", error=str(exc))
|
|
142
|
+
|
|
143
|
+
def _attach_rows(
|
|
144
|
+
self, w: WorkspaceClient, msg: Any, attachment_id: str, answer: GenieAnswer
|
|
145
|
+
) -> None:
|
|
146
|
+
res = w.genie.get_message_query_result_by_attachment(
|
|
147
|
+
self._space_id, msg.conversation_id, msg.message_id, attachment_id
|
|
148
|
+
)
|
|
149
|
+
sr = res.statement_response
|
|
150
|
+
if sr is None:
|
|
151
|
+
return
|
|
152
|
+
schema = sr.manifest.schema if sr.manifest else None
|
|
153
|
+
cols = (schema.columns or []) if schema else []
|
|
154
|
+
answer.columns = [c.name for c in cols if c.name]
|
|
155
|
+
data = (sr.result.data_array if sr.result else None) or []
|
|
156
|
+
answer.row_count = len(data)
|
|
157
|
+
answer.truncated = len(data) > _MAX_ROWS
|
|
158
|
+
answer.rows = [list(r) for r in data[:_MAX_ROWS]]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
__all__ = ["GenieAdapter", "GenieAnswer", "GenieUnavailableError"]
|
devmemory/api/app.py
CHANGED
|
@@ -17,12 +17,15 @@ from fastapi.responses import FileResponse, PlainTextResponse
|
|
|
17
17
|
from fastapi.staticfiles import StaticFiles
|
|
18
18
|
|
|
19
19
|
from devmemory.__about__ import __version__
|
|
20
|
+
from devmemory.adapters.genie import GenieAdapter, GenieAnswer, GenieUnavailableError
|
|
20
21
|
from devmemory.adapters.graph import GraphImpact
|
|
21
22
|
from devmemory.api import mappers
|
|
22
23
|
from devmemory.api.schemas import (
|
|
23
24
|
AgentCheckRequest,
|
|
24
25
|
ComparisonResponse,
|
|
25
26
|
FeatureDetail,
|
|
27
|
+
GenieAskRequest,
|
|
28
|
+
GenieStatus,
|
|
26
29
|
IssueRequest,
|
|
27
30
|
ProjectBriefDoc,
|
|
28
31
|
ProjectBriefRequest,
|
|
@@ -153,6 +156,30 @@ def create_app(repo_path: Path | str | None = None, *, enable_restore: bool = Fa
|
|
|
153
156
|
doc = briefsvc.set_brief(ctx, body.content)
|
|
154
157
|
return ProjectBriefDoc(content=doc.content, updated_at=doc.updated_at)
|
|
155
158
|
|
|
159
|
+
# -- Genie chat (Databricks) -------------------------------------
|
|
160
|
+
|
|
161
|
+
@app.get("/api/genie/status", response_model=GenieStatus)
|
|
162
|
+
def genie_status(ctx: Ctx) -> GenieStatus:
|
|
163
|
+
g = GenieAdapter(ctx.config)
|
|
164
|
+
return GenieStatus(
|
|
165
|
+
configured=g.is_configured,
|
|
166
|
+
space_id=g.space_id or None,
|
|
167
|
+
reason=g.unavailable_reason(),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
@app.post("/api/genie/ask", response_model=GenieAnswer)
|
|
171
|
+
def genie_ask(ctx: Ctx, body: GenieAskRequest) -> GenieAnswer:
|
|
172
|
+
question = body.question.strip()
|
|
173
|
+
if not question:
|
|
174
|
+
raise HTTPException(status_code=422, detail="question is empty")
|
|
175
|
+
g = GenieAdapter(ctx.config)
|
|
176
|
+
if not g.is_configured:
|
|
177
|
+
raise HTTPException(status_code=503, detail=g.unavailable_reason() or "Genie unavailable")
|
|
178
|
+
try:
|
|
179
|
+
return g.ask(question, conversation_id=body.conversation_id)
|
|
180
|
+
except GenieUnavailableError as exc:
|
|
181
|
+
raise HTTPException(status_code=502, detail=exc.message) from exc
|
|
182
|
+
|
|
156
183
|
# -- versions ------------------------------------------------------
|
|
157
184
|
|
|
158
185
|
@app.get("/api/versions", response_model=list[VersionListItem])
|
devmemory/api/schemas.py
CHANGED
|
@@ -181,11 +181,26 @@ class ProjectBriefRequest(BaseModel):
|
|
|
181
181
|
content: str
|
|
182
182
|
|
|
183
183
|
|
|
184
|
+
class GenieAskRequest(BaseModel):
|
|
185
|
+
"""One turn in the dashboard's Genie chat."""
|
|
186
|
+
|
|
187
|
+
question: str
|
|
188
|
+
conversation_id: str | None = None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class GenieStatus(BaseModel):
|
|
192
|
+
configured: bool
|
|
193
|
+
space_id: str | None = None
|
|
194
|
+
reason: str | None = None
|
|
195
|
+
|
|
196
|
+
|
|
184
197
|
__all__ = [
|
|
185
198
|
"AgentCheckRequest",
|
|
186
199
|
"ComparisonResponse",
|
|
187
200
|
"FeatureDetail",
|
|
188
201
|
"FeatureHistoryPoint",
|
|
202
|
+
"GenieAskRequest",
|
|
203
|
+
"GenieStatus",
|
|
189
204
|
"IssueRequest",
|
|
190
205
|
"MetricChange",
|
|
191
206
|
"ProjectBriefDoc",
|
devmemory/config.py
CHANGED
|
@@ -101,6 +101,11 @@ class DatabricksSettings(_Section):
|
|
|
101
101
|
enabled: bool = False
|
|
102
102
|
catalog: str = "devmemory"
|
|
103
103
|
schema_name: str = Field(default="analytics", alias="schema")
|
|
104
|
+
genie_space_id: str | None = Field(
|
|
105
|
+
default=None,
|
|
106
|
+
description="Genie space that backs the dashboard chat. "
|
|
107
|
+
"Overridden by DATABRICKS_GENIE_SPACE_ID.",
|
|
108
|
+
)
|
|
104
109
|
|
|
105
110
|
|
|
106
111
|
class RegressionSettings(_Section):
|