datacharter 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.
- datacharter/__init__.py +3 -0
- datacharter/agent/__init__.py +5 -0
- datacharter/agent/llm.py +119 -0
- datacharter/agent/loop.py +103 -0
- datacharter/agent/tools.py +146 -0
- datacharter/cli.py +249 -0
- datacharter/contracts/__init__.py +5 -0
- datacharter/contracts/loader.py +147 -0
- datacharter/contracts/resolve.py +74 -0
- datacharter/engine/__init__.py +1 -0
- datacharter/engine/aggregate.py +218 -0
- datacharter/engine/guard.py +142 -0
- datacharter/engine/pushdown.py +261 -0
- datacharter/engine/scrub.py +19 -0
- datacharter/engine/session.py +413 -0
- datacharter/engine/snowflake.py +153 -0
- datacharter/engine/sources.py +140 -0
- datacharter/models.py +72 -0
- datacharter/server/__init__.py +5 -0
- datacharter/server/app.py +355 -0
- datacharter/server/static/assets/abap-DLDM7-KI.js +1 -0
- datacharter/server/static/assets/apex-DNDY2TF8.js +1 -0
- datacharter/server/static/assets/azcli-Y6nb8tq_.js +1 -0
- datacharter/server/static/assets/bat-BwHxbl9M.js +1 -0
- datacharter/server/static/assets/bicep-CFznDFnq.js +2 -0
- datacharter/server/static/assets/cameligo-Bf6VGUru.js +1 -0
- datacharter/server/static/assets/clojure-Dnu-v4kV.js +1 -0
- datacharter/server/static/assets/codicon-Brq4_Ui5.ttf +0 -0
- datacharter/server/static/assets/coffee-Bd8akH9Z.js +1 -0
- datacharter/server/static/assets/cpp-BbWJElDN.js +1 -0
- datacharter/server/static/assets/csharp-Co3qMtFm.js +1 -0
- datacharter/server/static/assets/csp-D-4FJmMZ.js +1 -0
- datacharter/server/static/assets/css-DdJfP1eB.js +3 -0
- datacharter/server/static/assets/css.worker-CqwWTjeG.js +97 -0
- datacharter/server/static/assets/cssMode-BuAVOLr4.js +1 -0
- datacharter/server/static/assets/cypher-cTPe9QuQ.js +1 -0
- datacharter/server/static/assets/dart-BOtBlQCF.js +1 -0
- datacharter/server/static/assets/dockerfile-BG73LgW2.js +1 -0
- datacharter/server/static/assets/ecl-BEgZUVRK.js +1 -0
- datacharter/server/static/assets/editor.worker-DCKwvLbM.js +30 -0
- datacharter/server/static/assets/elixir-BkW5O-1t.js +1 -0
- datacharter/server/static/assets/flow9-BeJ5waoc.js +1 -0
- datacharter/server/static/assets/freemarker2-DM0thzwF.js +3 -0
- datacharter/server/static/assets/fsharp-PahG7c26.js +1 -0
- datacharter/server/static/assets/go-acbASCJo.js +1 -0
- datacharter/server/static/assets/graphql-BxJiqAUM.js +1 -0
- datacharter/server/static/assets/handlebars-BkMf3Qj4.js +1 -0
- datacharter/server/static/assets/hcl-DtV1sZF8.js +1 -0
- datacharter/server/static/assets/html-KeNy2AFd.js +1 -0
- datacharter/server/static/assets/html.worker-DhJ4VZMp.js +474 -0
- datacharter/server/static/assets/htmlMode-CN5rSB9p.js +1 -0
- datacharter/server/static/assets/index-BqWIVR2x.css +1 -0
- datacharter/server/static/assets/index-CCTlsFBM.js +295 -0
- datacharter/server/static/assets/ini-Kd9XrMLS.js +1 -0
- datacharter/server/static/assets/java-CXBNlu9o.js +1 -0
- datacharter/server/static/assets/javascript-CZu1iw4q.js +1 -0
- datacharter/server/static/assets/json.worker-BF9eV5mS.js +62 -0
- datacharter/server/static/assets/jsonMode-BtMXpE2Q.js +7 -0
- datacharter/server/static/assets/julia-cl7-CwDS.js +1 -0
- datacharter/server/static/assets/kotlin-s7OhZKlX.js +1 -0
- datacharter/server/static/assets/less-9HpZscsL.js +2 -0
- datacharter/server/static/assets/lexon-OrD6JF1K.js +1 -0
- datacharter/server/static/assets/liquid-mhqehfbE.js +1 -0
- datacharter/server/static/assets/lspLanguageFeatures-D1NXFkgg.js +4 -0
- datacharter/server/static/assets/lua-Cyyb5UIc.js +1 -0
- datacharter/server/static/assets/m3-B8OfTtLu.js +1 -0
- datacharter/server/static/assets/markdown-BFxVWTOG.js +1 -0
- datacharter/server/static/assets/mdx-69yOd9f1.js +1 -0
- datacharter/server/static/assets/mips-CiqrrVzr.js +1 -0
- datacharter/server/static/assets/monaco-CB2SckMT.css +1 -0
- datacharter/server/static/assets/monaco-DNze5gXB.js +930 -0
- datacharter/server/static/assets/msdax-DmeGPVcC.js +1 -0
- datacharter/server/static/assets/mysql-C_tMU-Nz.js +1 -0
- datacharter/server/static/assets/objective-c-BDtDVThU.js +1 -0
- datacharter/server/static/assets/pascal-vHIfCaH5.js +1 -0
- datacharter/server/static/assets/pascaligo-DtZ0uQbO.js +1 -0
- datacharter/server/static/assets/perl-Ub6l9XKa.js +1 -0
- datacharter/server/static/assets/pgsql-BlNEE0v7.js +1 -0
- datacharter/server/static/assets/php-BBUBE1dy.js +1 -0
- datacharter/server/static/assets/pla-DSh2-awV.js +1 -0
- datacharter/server/static/assets/postiats-CocnycG-.js +1 -0
- datacharter/server/static/assets/powerquery-tScXyioY.js +1 -0
- datacharter/server/static/assets/powershell-COWaemsV.js +1 -0
- datacharter/server/static/assets/protobuf-Brw8urJB.js +2 -0
- datacharter/server/static/assets/pug-8SOpv6rk.js +1 -0
- datacharter/server/static/assets/python-C5qpjWC4.js +1 -0
- datacharter/server/static/assets/qsharp-Bw9ernYp.js +1 -0
- datacharter/server/static/assets/r-j7ic8hl3.js +1 -0
- datacharter/server/static/assets/razor-fg0l3XC9.js +1 -0
- datacharter/server/static/assets/redis-Bu5POkcn.js +1 -0
- datacharter/server/static/assets/redshift-Bs9aos_-.js +1 -0
- datacharter/server/static/assets/restructuredtext-CqXO7rUv.js +1 -0
- datacharter/server/static/assets/ruby-zBfavPgS.js +1 -0
- datacharter/server/static/assets/rust-BzKRNQWT.js +1 -0
- datacharter/server/static/assets/sb-BBc9UKZt.js +1 -0
- datacharter/server/static/assets/scala-D9hQfWCl.js +1 -0
- datacharter/server/static/assets/scheme-BPhDTwHR.js +1 -0
- datacharter/server/static/assets/scss-CBJaRo0y.js +3 -0
- datacharter/server/static/assets/shell-DiJ1NA_G.js +1 -0
- datacharter/server/static/assets/solidity-Db0IVjzk.js +1 -0
- datacharter/server/static/assets/sophia-CnS9iZB_.js +1 -0
- datacharter/server/static/assets/sparql-CJmd_6j2.js +1 -0
- datacharter/server/static/assets/sql-ClhHkBeG.js +1 -0
- datacharter/server/static/assets/st-CHwy0fLd.js +1 -0
- datacharter/server/static/assets/swift-Bqt4WxQ4.js +3 -0
- datacharter/server/static/assets/systemverilog-Bs9z6M-B.js +1 -0
- datacharter/server/static/assets/tcl-Dm6ycUr_.js +1 -0
- datacharter/server/static/assets/ts.worker-DTZAwq0V.js +67735 -0
- datacharter/server/static/assets/tsMode-CaXNdXrQ.js +11 -0
- datacharter/server/static/assets/twig-Csy3S7wG.js +1 -0
- datacharter/server/static/assets/typescript-CCbVPqil.js +1 -0
- datacharter/server/static/assets/typespec-Btyra-wh.js +1 -0
- datacharter/server/static/assets/vb-Db0cS2oM.js +1 -0
- datacharter/server/static/assets/wgsl-BTesnYfV.js +298 -0
- datacharter/server/static/assets/workers-CwEVRSMl.js +1 -0
- datacharter/server/static/assets/xml-CIpcyvha.js +1 -0
- datacharter/server/static/assets/yaml-CiikAWeE.js +1 -0
- datacharter/server/static/index.html +15 -0
- datacharter-0.1.0.dist-info/METADATA +115 -0
- datacharter-0.1.0.dist-info/RECORD +123 -0
- datacharter-0.1.0.dist-info/WHEEL +4 -0
- datacharter-0.1.0.dist-info/entry_points.txt +2 -0
- datacharter-0.1.0.dist-info/licenses/LICENSE +202 -0
datacharter/__init__.py
ADDED
datacharter/agent/llm.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Minimal OpenAI-compatible chat client (httpx, no vendor SDK — DESIGN D2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
__all__ = ["LLMClient", "LLMError", "ToolCall", "Delta"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LLMError(Exception):
|
|
17
|
+
"""LLM endpoint failure (connection, auth, or protocol)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ToolCall:
|
|
22
|
+
id: str
|
|
23
|
+
name: str
|
|
24
|
+
arguments: str # raw JSON string as emitted by the model
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Delta:
|
|
29
|
+
"""One streamed step: text fragment and/or the finished tool calls."""
|
|
30
|
+
|
|
31
|
+
text: str = ""
|
|
32
|
+
tool_calls: list[ToolCall] | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LLMClient:
|
|
36
|
+
"""Talks to any /chat/completions endpoint (OpenAI, Ollama, vLLM, …)."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
base_url: str | None = None,
|
|
42
|
+
api_key: str | None = None,
|
|
43
|
+
model: str | None = None,
|
|
44
|
+
timeout_s: float = 120.0,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.base_url = (
|
|
47
|
+
base_url or os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
|
48
|
+
).rstrip("/")
|
|
49
|
+
self.api_key = api_key or os.environ.get("OPENAI_API_KEY", "")
|
|
50
|
+
self.model = model or os.environ.get("DATACHARTER_MODEL", "gpt-4o-mini")
|
|
51
|
+
self.timeout_s = timeout_s
|
|
52
|
+
|
|
53
|
+
async def stream(
|
|
54
|
+
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]
|
|
55
|
+
) -> AsyncIterator[Delta]:
|
|
56
|
+
"""Stream a completion, yielding text deltas and assembling tool calls."""
|
|
57
|
+
payload = {
|
|
58
|
+
"model": self.model,
|
|
59
|
+
"messages": messages,
|
|
60
|
+
"tools": tools,
|
|
61
|
+
"stream": True,
|
|
62
|
+
}
|
|
63
|
+
headers = {"content-type": "application/json"}
|
|
64
|
+
if self.api_key:
|
|
65
|
+
headers["authorization"] = f"Bearer {self.api_key}"
|
|
66
|
+
|
|
67
|
+
pending: dict[int, dict[str, str]] = {}
|
|
68
|
+
try:
|
|
69
|
+
async with (
|
|
70
|
+
httpx.AsyncClient(timeout=self.timeout_s) as client,
|
|
71
|
+
client.stream(
|
|
72
|
+
"POST", f"{self.base_url}/chat/completions", json=payload, headers=headers
|
|
73
|
+
) as resp,
|
|
74
|
+
):
|
|
75
|
+
if resp.status_code >= 400:
|
|
76
|
+
body = (await resp.aread()).decode("utf-8", "replace")[:500]
|
|
77
|
+
raise LLMError(f"LLM endpoint returned {resp.status_code}: {body}")
|
|
78
|
+
async for line in resp.aiter_lines():
|
|
79
|
+
delta = _parse_sse_line(line, pending)
|
|
80
|
+
if delta is not None:
|
|
81
|
+
yield delta
|
|
82
|
+
except httpx.HTTPError as exc:
|
|
83
|
+
raise LLMError(f"Could not reach LLM at {self.base_url}: {exc}") from None
|
|
84
|
+
|
|
85
|
+
if pending:
|
|
86
|
+
calls = [
|
|
87
|
+
ToolCall(id=c["id"], name=c["name"], arguments=c["arguments"])
|
|
88
|
+
for c in pending.values()
|
|
89
|
+
if c.get("name")
|
|
90
|
+
]
|
|
91
|
+
if calls:
|
|
92
|
+
yield Delta(tool_calls=calls)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _parse_sse_line(line: str, pending: dict[int, dict[str, str]]) -> Delta | None:
|
|
96
|
+
if not line.startswith("data:"):
|
|
97
|
+
return None
|
|
98
|
+
data = line[len("data:") :].strip()
|
|
99
|
+
if not data or data == "[DONE]":
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
chunk = json.loads(data)
|
|
103
|
+
except json.JSONDecodeError:
|
|
104
|
+
return None
|
|
105
|
+
choices = chunk.get("choices") or [{}]
|
|
106
|
+
delta = choices[0].get("delta") or {}
|
|
107
|
+
|
|
108
|
+
text = delta.get("content") or ""
|
|
109
|
+
for tc in delta.get("tool_calls") or []:
|
|
110
|
+
idx = tc.get("index", 0)
|
|
111
|
+
slot = pending.setdefault(idx, {"id": "", "name": "", "arguments": ""})
|
|
112
|
+
if tc.get("id"):
|
|
113
|
+
slot["id"] = tc["id"]
|
|
114
|
+
fn = tc.get("function") or {}
|
|
115
|
+
if fn.get("name"):
|
|
116
|
+
slot["name"] = fn["name"]
|
|
117
|
+
if fn.get("arguments"):
|
|
118
|
+
slot["arguments"] += fn["arguments"]
|
|
119
|
+
return Delta(text=text) if text else None
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The agent loop: stream text, execute tool calls, feed results back until done."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from datacharter.agent.llm import LLMClient, LLMError
|
|
10
|
+
from datacharter.agent.tools import TOOL_SPECS, ToolBox
|
|
11
|
+
|
|
12
|
+
__all__ = ["Agent", "AgentConfig", "AgentEvent"]
|
|
13
|
+
|
|
14
|
+
MAX_TURNS = 8
|
|
15
|
+
|
|
16
|
+
SYSTEM_PROMPT = """\
|
|
17
|
+
You are DataCharter's data analyst. You answer questions about the user's data
|
|
18
|
+
by exploring the catalog and running read-only SQL through your tools.
|
|
19
|
+
|
|
20
|
+
Rules:
|
|
21
|
+
- Discover before querying: use list_tables / describe_table to learn the
|
|
22
|
+
schema. Never guess column names.
|
|
23
|
+
- Use fully-qualified relation names exactly as list_tables reports them.
|
|
24
|
+
- Queries are read-only. Keep exploratory queries small with LIMIT.
|
|
25
|
+
- Some columns are PII and come back masked as •••; never claim to reveal them.
|
|
26
|
+
- When a result is best seen as a chart, end your answer with a fenced
|
|
27
|
+
```vega-lite block containing a valid Vega-Lite spec whose data.values you
|
|
28
|
+
fill from your query results.
|
|
29
|
+
- Be concise. Show the SQL you ran.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class AgentConfig:
|
|
35
|
+
llm: LLMClient = field(default_factory=LLMClient)
|
|
36
|
+
max_turns: int = MAX_TURNS
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class AgentEvent:
|
|
41
|
+
kind: Literal["text", "tool_call", "tool_result", "error", "done"]
|
|
42
|
+
text: str = ""
|
|
43
|
+
tool: str = ""
|
|
44
|
+
detail: str = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Agent:
|
|
48
|
+
"""One conversational turn = many LLM/tool rounds until a final answer."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, tools: ToolBox, config: AgentConfig | None = None) -> None:
|
|
51
|
+
self._tools = tools
|
|
52
|
+
self._config = config or AgentConfig()
|
|
53
|
+
|
|
54
|
+
async def run(
|
|
55
|
+
self, question: str, history: list[dict[str, Any]] | None = None
|
|
56
|
+
) -> AsyncIterator[AgentEvent]:
|
|
57
|
+
messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
|
|
58
|
+
messages.extend(history or [])
|
|
59
|
+
messages.append({"role": "user", "content": question})
|
|
60
|
+
|
|
61
|
+
for _turn in range(self._config.max_turns):
|
|
62
|
+
assistant_text = ""
|
|
63
|
+
tool_calls = None
|
|
64
|
+
try:
|
|
65
|
+
async for delta in self._config.llm.stream(messages, TOOL_SPECS):
|
|
66
|
+
if delta.text:
|
|
67
|
+
assistant_text += delta.text
|
|
68
|
+
yield AgentEvent(kind="text", text=delta.text)
|
|
69
|
+
if delta.tool_calls:
|
|
70
|
+
tool_calls = delta.tool_calls
|
|
71
|
+
except LLMError as exc:
|
|
72
|
+
yield AgentEvent(kind="error", detail=str(exc))
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
if not tool_calls:
|
|
76
|
+
yield AgentEvent(kind="done")
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
messages.append(
|
|
80
|
+
{
|
|
81
|
+
"role": "assistant",
|
|
82
|
+
"content": assistant_text or None,
|
|
83
|
+
"tool_calls": [
|
|
84
|
+
{
|
|
85
|
+
"id": c.id,
|
|
86
|
+
"type": "function",
|
|
87
|
+
"function": {"name": c.name, "arguments": c.arguments},
|
|
88
|
+
}
|
|
89
|
+
for c in tool_calls
|
|
90
|
+
],
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
for call in tool_calls:
|
|
94
|
+
yield AgentEvent(kind="tool_call", tool=call.name, detail=call.arguments)
|
|
95
|
+
result = await self._tools.run(call.name, call.arguments)
|
|
96
|
+
yield AgentEvent(kind="tool_result", tool=call.name, detail=result)
|
|
97
|
+
messages.append(
|
|
98
|
+
{"role": "tool", "tool_call_id": call.id, "content": result}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
yield AgentEvent(
|
|
102
|
+
kind="error", detail=f"Stopped after {self._config.max_turns} tool rounds."
|
|
103
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Agent tools over the engine: catalog introspection + guarded querying with PII masking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from datacharter.engine.session import Engine
|
|
9
|
+
from datacharter.models import QueryResult, Source
|
|
10
|
+
|
|
11
|
+
__all__ = ["ToolBox", "TOOL_SPECS"]
|
|
12
|
+
|
|
13
|
+
TOOL_SPECS: list[dict[str, Any]] = [
|
|
14
|
+
{
|
|
15
|
+
"type": "function",
|
|
16
|
+
"function": {
|
|
17
|
+
"name": "list_sources",
|
|
18
|
+
"description": "List configured data sources with their types.",
|
|
19
|
+
"parameters": {"type": "object", "properties": {}},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"type": "function",
|
|
24
|
+
"function": {
|
|
25
|
+
"name": "list_tables",
|
|
26
|
+
"description": "List all queryable tables with their fully-qualified relation names.",
|
|
27
|
+
"parameters": {"type": "object", "properties": {}},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"type": "function",
|
|
32
|
+
"function": {
|
|
33
|
+
"name": "describe_table",
|
|
34
|
+
"description": "Show columns and types for one relation (e.g. 'crm.customers').",
|
|
35
|
+
"parameters": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {"relation": {"type": "string"}},
|
|
38
|
+
"required": ["relation"],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"type": "function",
|
|
44
|
+
"function": {
|
|
45
|
+
"name": "query",
|
|
46
|
+
"description": (
|
|
47
|
+
"Run a read-only SQL query and return rows. Use fully-qualified "
|
|
48
|
+
"relation names. Prefer LIMIT for exploration."
|
|
49
|
+
),
|
|
50
|
+
"parameters": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"properties": {"sql": {"type": "string"}},
|
|
53
|
+
"required": ["sql"],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
MASKED = "•••"
|
|
60
|
+
_MAX_TOOL_ROWS = 50
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ToolBox:
|
|
64
|
+
"""Executes tool calls against the engine; masks PII columns in returned data."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, engine: Engine, sources: list[Source]) -> None:
|
|
67
|
+
self._engine = engine
|
|
68
|
+
# Column names flagged PII in any source's contract, matched case-insensitively.
|
|
69
|
+
self._pii: set[str] = set()
|
|
70
|
+
for src in sources:
|
|
71
|
+
for cols in src.pii.values():
|
|
72
|
+
self._pii.update(c.lower() for c in cols)
|
|
73
|
+
|
|
74
|
+
async def run(self, name: str, arguments: str) -> str:
|
|
75
|
+
try:
|
|
76
|
+
args = json.loads(arguments or "{}")
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
return "Error: tool arguments were not valid JSON."
|
|
79
|
+
handler = {
|
|
80
|
+
"list_sources": self._list_sources,
|
|
81
|
+
"list_tables": self._list_tables,
|
|
82
|
+
"describe_table": self._describe_table,
|
|
83
|
+
"query": self._query,
|
|
84
|
+
}.get(name)
|
|
85
|
+
if handler is None:
|
|
86
|
+
return f"Error: unknown tool '{name}'."
|
|
87
|
+
try:
|
|
88
|
+
return await handler(args)
|
|
89
|
+
except Exception as exc: # engine errors already scrubbed; surface to the model
|
|
90
|
+
return f"Error: {exc}"
|
|
91
|
+
|
|
92
|
+
async def _list_sources(self, _args: dict) -> str:
|
|
93
|
+
rows = [{"name": s.name, "type": s.type.value} for s in self._engine.sources]
|
|
94
|
+
return json.dumps(rows)
|
|
95
|
+
|
|
96
|
+
async def _list_tables(self, _args: dict) -> str:
|
|
97
|
+
result = await self._engine.query("SHOW ALL TABLES", timeout_s=30)
|
|
98
|
+
idx = {c: i for i, c in enumerate(result.columns)}
|
|
99
|
+
out = []
|
|
100
|
+
for row in result.rows:
|
|
101
|
+
db = row[idx["database"]]
|
|
102
|
+
if db in ("system", "temp"):
|
|
103
|
+
continue
|
|
104
|
+
table = row[idx["name"]]
|
|
105
|
+
relation = table if db == "memory" else f"{db}.{table}"
|
|
106
|
+
out.append({"relation": relation, "columns": list(row[idx["column_names"]])})
|
|
107
|
+
return json.dumps(out)
|
|
108
|
+
|
|
109
|
+
async def _describe_table(self, args: dict) -> str:
|
|
110
|
+
relation = str(args.get("relation", ""))
|
|
111
|
+
if not _is_safe_relation(relation):
|
|
112
|
+
return "Error: invalid relation name."
|
|
113
|
+
result = await self._engine.query(f"DESCRIBE {relation}", timeout_s=30)
|
|
114
|
+
return self._render(result)
|
|
115
|
+
|
|
116
|
+
async def _query(self, args: dict) -> str:
|
|
117
|
+
sql = str(args.get("sql", ""))
|
|
118
|
+
result = await self._engine.query(sql, row_limit=_MAX_TOOL_ROWS)
|
|
119
|
+
return self._render(result)
|
|
120
|
+
|
|
121
|
+
def _render(self, result: QueryResult) -> str:
|
|
122
|
+
mask_idx = {
|
|
123
|
+
i for i, col in enumerate(result.columns) if col.lower() in self._pii
|
|
124
|
+
}
|
|
125
|
+
rows = [
|
|
126
|
+
[MASKED if i in mask_idx else v for i, v in enumerate(row)]
|
|
127
|
+
for row in result.rows
|
|
128
|
+
]
|
|
129
|
+
payload = {
|
|
130
|
+
"columns": result.columns,
|
|
131
|
+
"rows": rows,
|
|
132
|
+
"row_count": result.row_count,
|
|
133
|
+
"truncated": result.truncated,
|
|
134
|
+
}
|
|
135
|
+
if result.warnings:
|
|
136
|
+
payload["warnings"] = result.warnings
|
|
137
|
+
if mask_idx:
|
|
138
|
+
payload["masked_columns"] = [result.columns[i] for i in mask_idx]
|
|
139
|
+
return json.dumps(payload, default=str)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _is_safe_relation(relation: str) -> bool:
|
|
143
|
+
parts = relation.split(".")
|
|
144
|
+
return 1 <= len(parts) <= 3 and all(
|
|
145
|
+
p and all(ch.isalnum() or ch == "_" for ch in p) for p in parts
|
|
146
|
+
)
|
datacharter/cli.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Command-line entrypoint: init (workspace scaffolding), serve (M3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from datacharter import __version__
|
|
11
|
+
|
|
12
|
+
CHARTER_TEMPLATE = """\
|
|
13
|
+
# DataCharter workspace — https://github.com/datacharter/datacharter
|
|
14
|
+
# Sources are data contracts: connection shape here, secrets NEVER here.
|
|
15
|
+
# Credentials must be ${NAME} references resolved from your environment,
|
|
16
|
+
# .env, or the OS keyring (`datacharter secrets set NAME`).
|
|
17
|
+
version: 1
|
|
18
|
+
|
|
19
|
+
sources: {}
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
DEMO_CHARTER = """\
|
|
23
|
+
# DataCharter demo workspace. Run: datacharter serve
|
|
24
|
+
version: 1
|
|
25
|
+
|
|
26
|
+
sources:
|
|
27
|
+
customers:
|
|
28
|
+
type: csv
|
|
29
|
+
path: demo/customers.csv
|
|
30
|
+
pii:
|
|
31
|
+
customers: [email]
|
|
32
|
+
|
|
33
|
+
orders:
|
|
34
|
+
type: parquet
|
|
35
|
+
path: demo/orders.parquet
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
ENV_EXAMPLE = """\
|
|
39
|
+
# Copy to .env and fill in real values. .env is gitignored.
|
|
40
|
+
# EXAMPLE_DB_PASSWORD=change-me
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
GITIGNORE_BLOCK = """\
|
|
44
|
+
# DataCharter local state (never commit)
|
|
45
|
+
.env
|
|
46
|
+
.datacharter/
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _cmd_init(args: argparse.Namespace) -> int:
|
|
51
|
+
ws = Path(args.directory).resolve()
|
|
52
|
+
ws.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
charter = ws / "charter.yaml"
|
|
54
|
+
if charter.exists() and not args.force:
|
|
55
|
+
print(f"charter.yaml already exists in {ws} (use --force to overwrite).")
|
|
56
|
+
return 1
|
|
57
|
+
|
|
58
|
+
charter.write_text(DEMO_CHARTER if args.demo else CHARTER_TEMPLATE)
|
|
59
|
+
(ws / ".env.example").write_text(ENV_EXAMPLE)
|
|
60
|
+
(ws / "queries").mkdir(exist_ok=True)
|
|
61
|
+
_ensure_gitignore(ws)
|
|
62
|
+
if args.demo:
|
|
63
|
+
_write_demo_data(ws)
|
|
64
|
+
print(f"Workspace initialized in {ws}.")
|
|
65
|
+
if args.demo:
|
|
66
|
+
print("Demo data in demo/ — try: datacharter serve")
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _ensure_gitignore(ws: Path) -> None:
|
|
71
|
+
gi = ws / ".gitignore"
|
|
72
|
+
existing = gi.read_text() if gi.exists() else ""
|
|
73
|
+
if ".datacharter/" not in existing:
|
|
74
|
+
joiner = "\n" if existing and not existing.endswith("\n") else ""
|
|
75
|
+
gi.write_text(existing + joiner + GITIGNORE_BLOCK)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _write_demo_data(ws: Path) -> None:
|
|
79
|
+
import duckdb
|
|
80
|
+
|
|
81
|
+
demo = ws / "demo"
|
|
82
|
+
demo.mkdir(exist_ok=True)
|
|
83
|
+
con = duckdb.connect()
|
|
84
|
+
con.execute(
|
|
85
|
+
"COPY (SELECT * FROM (VALUES "
|
|
86
|
+
"(1, 'ada@example.com', 'pro'), (2, 'grace@example.com', 'free'), "
|
|
87
|
+
"(3, 'edsger@example.com', 'pro')) t(id, email, tier)) TO "
|
|
88
|
+
f"'{demo / 'customers.csv'}' (FORMAT csv, HEADER)"
|
|
89
|
+
)
|
|
90
|
+
con.execute(
|
|
91
|
+
"COPY (SELECT (i % 3) + 1 AS customer_id, round(random() * 200, 2) AS total, "
|
|
92
|
+
"DATE '2026-01-01' + INTERVAL (i) DAY AS placed_on "
|
|
93
|
+
f"FROM range(90) t(i)) TO '{demo / 'orders.parquet'}' (FORMAT parquet)"
|
|
94
|
+
)
|
|
95
|
+
con.close()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _resolve_serve_workspace(directory: str) -> Path:
|
|
99
|
+
"""Workspace for serve; ephemeral demo when no charter.yaml (D3, no surprise writes)."""
|
|
100
|
+
ws = Path(directory).resolve()
|
|
101
|
+
if (ws / "charter.yaml").exists():
|
|
102
|
+
return ws
|
|
103
|
+
import tempfile
|
|
104
|
+
|
|
105
|
+
demo_ws = Path(tempfile.mkdtemp(prefix="datacharter-demo-"))
|
|
106
|
+
(demo_ws / "charter.yaml").write_text(DEMO_CHARTER)
|
|
107
|
+
_write_demo_data(demo_ws)
|
|
108
|
+
print(f"No charter.yaml in {ws} — serving an ephemeral demo workspace ({demo_ws}).")
|
|
109
|
+
print("Run `datacharter init` here to start a real workspace.")
|
|
110
|
+
return demo_ws
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
LOCAL_BASE_URL = "http://127.0.0.1:11434/v1"
|
|
114
|
+
LOCAL_DEFAULT_MODEL = "qwen3:8b"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _local_llm(model: str | None):
|
|
118
|
+
"""Point the agent at a local Ollama; verify it's reachable, hint if not."""
|
|
119
|
+
import httpx
|
|
120
|
+
|
|
121
|
+
from datacharter.agent.llm import LLMClient
|
|
122
|
+
|
|
123
|
+
chosen = model or LOCAL_DEFAULT_MODEL
|
|
124
|
+
try:
|
|
125
|
+
httpx.get("http://127.0.0.1:11434/api/tags", timeout=2.0).raise_for_status()
|
|
126
|
+
except Exception:
|
|
127
|
+
print("Ollama not reachable at 127.0.0.1:11434.")
|
|
128
|
+
print("Install from https://ollama.com, then: ollama pull " + chosen)
|
|
129
|
+
print("Serving without a local agent (the UI still works; chat will be disabled).")
|
|
130
|
+
return None
|
|
131
|
+
print(f"Local agent: Ollama model '{chosen}'. Pull it with: ollama pull {chosen}")
|
|
132
|
+
return LLMClient(base_url=LOCAL_BASE_URL, api_key="ollama", model=chosen)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _cmd_serve(args: argparse.Namespace) -> int:
|
|
136
|
+
import uvicorn
|
|
137
|
+
|
|
138
|
+
from datacharter.server import create_app
|
|
139
|
+
|
|
140
|
+
ws = _resolve_serve_workspace(args.directory)
|
|
141
|
+
llm = _local_llm(args.model) if args.local else None
|
|
142
|
+
app = create_app(ws, allow_spill=not args.no_spill, llm=llm, host=args.host)
|
|
143
|
+
print(f"DataCharter serving {ws} on http://{args.host}:{args.port}")
|
|
144
|
+
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
_SECRETS_INDEX = "__datacharter_index__" # reserved key tracking stored names for `list`
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _secret_index(kr, service: str) -> list[str]:
|
|
152
|
+
try:
|
|
153
|
+
raw = kr.get_password(service, _SECRETS_INDEX)
|
|
154
|
+
except Exception:
|
|
155
|
+
return []
|
|
156
|
+
return [n for n in (raw or "").split(",") if n]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _cmd_secrets(args: argparse.Namespace) -> int:
|
|
160
|
+
import getpass
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
import keyring
|
|
164
|
+
except ImportError:
|
|
165
|
+
print("The 'keyring' package is required for secret storage.", file=sys.stderr)
|
|
166
|
+
return 1
|
|
167
|
+
from datacharter.contracts.resolve import KEYRING_SERVICE
|
|
168
|
+
|
|
169
|
+
if args.action == "set":
|
|
170
|
+
value = args.value
|
|
171
|
+
if value is None:
|
|
172
|
+
value = getpass.getpass(f"Value for {args.name}: ")
|
|
173
|
+
if not value:
|
|
174
|
+
print("No value provided; nothing stored.", file=sys.stderr)
|
|
175
|
+
return 1
|
|
176
|
+
try:
|
|
177
|
+
keyring.set_password(KEYRING_SERVICE, args.name, value)
|
|
178
|
+
names = sorted(set(_secret_index(keyring, KEYRING_SERVICE)) | {args.name})
|
|
179
|
+
keyring.set_password(KEYRING_SERVICE, _SECRETS_INDEX, ",".join(names))
|
|
180
|
+
except Exception as exc:
|
|
181
|
+
print(f"Could not store secret (no keyring backend?): {exc}", file=sys.stderr)
|
|
182
|
+
return 1
|
|
183
|
+
print(f"Stored '{args.name}' in the OS keyring.")
|
|
184
|
+
return 0
|
|
185
|
+
if args.action == "rm":
|
|
186
|
+
with contextlib.suppress(Exception):
|
|
187
|
+
keyring.delete_password(KEYRING_SERVICE, args.name)
|
|
188
|
+
names = [n for n in _secret_index(keyring, KEYRING_SERVICE) if n != args.name]
|
|
189
|
+
with contextlib.suppress(Exception):
|
|
190
|
+
keyring.set_password(KEYRING_SERVICE, _SECRETS_INDEX, ",".join(names))
|
|
191
|
+
print(f"Removed '{args.name}'.")
|
|
192
|
+
return 0
|
|
193
|
+
names = _secret_index(keyring, KEYRING_SERVICE)
|
|
194
|
+
if names:
|
|
195
|
+
print("\n".join(names))
|
|
196
|
+
else:
|
|
197
|
+
print("No secrets stored via datacharter (env and .env are not listed here).")
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main(argv: list[str] | None = None) -> int:
|
|
202
|
+
parser = argparse.ArgumentParser(prog="datacharter", description="Charter your data.")
|
|
203
|
+
parser.add_argument("--version", action="version", version=f"datacharter {__version__}")
|
|
204
|
+
sub = parser.add_subparsers(dest="command")
|
|
205
|
+
|
|
206
|
+
p_init = sub.add_parser(
|
|
207
|
+
"init", help="Scaffold a workspace (charter.yaml, queries/, .env.example)"
|
|
208
|
+
)
|
|
209
|
+
p_init.add_argument("directory", nargs="?", default=".")
|
|
210
|
+
p_init.add_argument("--demo", action="store_true", help="Include a generated demo dataset")
|
|
211
|
+
p_init.add_argument("--force", action="store_true", help="Overwrite an existing charter.yaml")
|
|
212
|
+
p_init.set_defaults(func=_cmd_init)
|
|
213
|
+
|
|
214
|
+
p_serve = sub.add_parser("serve", help="Start the local server")
|
|
215
|
+
p_serve.add_argument("directory", nargs="?", default=".")
|
|
216
|
+
p_serve.add_argument(
|
|
217
|
+
"--host", default="127.0.0.1", help="Bind address (default: localhost only)"
|
|
218
|
+
)
|
|
219
|
+
p_serve.add_argument("--port", type=int, default=8321)
|
|
220
|
+
p_serve.add_argument(
|
|
221
|
+
"--no-spill", action="store_true", help="Fail queries instead of spilling to disk"
|
|
222
|
+
)
|
|
223
|
+
p_serve.add_argument(
|
|
224
|
+
"--local", action="store_true", help="Use a local Ollama model for the agent"
|
|
225
|
+
)
|
|
226
|
+
p_serve.add_argument("--model", help="Model name (with --local; default qwen3:8b)")
|
|
227
|
+
p_serve.set_defaults(func=_cmd_serve)
|
|
228
|
+
|
|
229
|
+
p_secrets = sub.add_parser("secrets", help="Manage ${NAME} secrets in the OS keyring")
|
|
230
|
+
sec_sub = p_secrets.add_subparsers(dest="action")
|
|
231
|
+
p_set = sec_sub.add_parser("set", help="Store a secret (prompts if --value omitted)")
|
|
232
|
+
p_set.add_argument("name")
|
|
233
|
+
p_set.add_argument("--value", help="Value (omit to be prompted without echo)")
|
|
234
|
+
sec_sub.add_parser("rm", help="Remove a secret").add_argument("name")
|
|
235
|
+
sec_sub.add_parser("list", help="List secret names stored via datacharter")
|
|
236
|
+
p_secrets.set_defaults(func=_cmd_secrets)
|
|
237
|
+
|
|
238
|
+
args = parser.parse_args(argv)
|
|
239
|
+
if args.command == "secrets" and not getattr(args, "action", None):
|
|
240
|
+
p_secrets.print_help()
|
|
241
|
+
return 1
|
|
242
|
+
if not hasattr(args, "func"):
|
|
243
|
+
parser.print_help()
|
|
244
|
+
return 1
|
|
245
|
+
return args.func(args)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
sys.exit(main())
|