plotaviz 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.
- plotaviz/__init__.py +19 -0
- plotaviz/core/__init__.py +53 -0
- plotaviz/core/analysis.py +230 -0
- plotaviz/core/codegen.py +482 -0
- plotaviz/core/errors.py +65 -0
- plotaviz/core/exporter.py +145 -0
- plotaviz/core/llm/__init__.py +211 -0
- plotaviz/core/llm/anthropic.py +61 -0
- plotaviz/core/llm/base.py +373 -0
- plotaviz/core/llm/gemini.py +51 -0
- plotaviz/core/llm/ollama.py +113 -0
- plotaviz/core/llm/openai.py +58 -0
- plotaviz/core/loader.py +366 -0
- plotaviz/core/plotter.py +790 -0
- plotaviz/core/preprocess.py +836 -0
- plotaviz/core/profiler.py +509 -0
- plotaviz/core/rules.yaml +190 -0
- plotaviz/core/selector.py +495 -0
- plotaviz/core/session.py +174 -0
- plotaviz/core/spec.py +236 -0
- plotaviz/main.py +329 -0
- plotaviz/ui/__init__.py +23 -0
- plotaviz/ui/export_dialog.py +228 -0
- plotaviz/ui/filter_panel.py +273 -0
- plotaviz/ui/interactive_view.py +210 -0
- plotaviz/ui/main_window.py +859 -0
- plotaviz/ui/nl_query_bar.py +219 -0
- plotaviz/ui/settings_dialog.py +313 -0
- plotaviz/ui/table_model.py +184 -0
- plotaviz/ui/type_override.py +159 -0
- plotaviz/ui/workers.py +222 -0
- plotaviz-0.1.0.dist-info/METADATA +410 -0
- plotaviz-0.1.0.dist-info/RECORD +37 -0
- plotaviz-0.1.0.dist-info/WHEEL +5 -0
- plotaviz-0.1.0.dist-info/entry_points.txt +2 -0
- plotaviz-0.1.0.dist-info/licenses/LICENSE +21 -0
- plotaviz-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""The LLM layer — optional intelligence on top of a fully working local engine.
|
|
2
|
+
|
|
3
|
+
PlotaViz is **offline-first**. The rules + scoring engine answers instantly with no provider
|
|
4
|
+
configured, and it is the fallback whenever an LLM call fails, times out, or returns something
|
|
5
|
+
that does not validate. A model is never on the critical path.
|
|
6
|
+
|
|
7
|
+
The layer does two jobs:
|
|
8
|
+
|
|
9
|
+
1. **A second opinion on selection** when the local scores are close enough that the ranking is
|
|
10
|
+
effectively a coin flip.
|
|
11
|
+
2. **Natural language → chart spec**, which is the only way to get from "revenue by region over
|
|
12
|
+
time" to a rendered chart.
|
|
13
|
+
|
|
14
|
+
Both return a *validated chart spec*. No code is ever generated, returned, or executed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from ..errors import LLMError, ProviderNotConfigured
|
|
23
|
+
from ..profiler import DatasetProfile
|
|
24
|
+
from ..spec import ChartSpec
|
|
25
|
+
from .anthropic import AnthropicProvider
|
|
26
|
+
from .base import (
|
|
27
|
+
KEYRING_SERVICE,
|
|
28
|
+
LLMResult,
|
|
29
|
+
Provider,
|
|
30
|
+
build_prompt,
|
|
31
|
+
delete_api_key,
|
|
32
|
+
extract_json,
|
|
33
|
+
get_api_key,
|
|
34
|
+
redact,
|
|
35
|
+
sample_rows_for,
|
|
36
|
+
set_api_key,
|
|
37
|
+
)
|
|
38
|
+
from .gemini import GeminiProvider
|
|
39
|
+
from .ollama import OllamaProvider
|
|
40
|
+
from .openai import OpenAIProvider
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
#: Every provider PlotaViz can use, keyed by the name stored in settings and the keyring.
|
|
45
|
+
PROVIDERS: dict[str, type[Provider]] = {
|
|
46
|
+
AnthropicProvider.name: AnthropicProvider,
|
|
47
|
+
OpenAIProvider.name: OpenAIProvider,
|
|
48
|
+
GeminiProvider.name: GeminiProvider,
|
|
49
|
+
OllamaProvider.name: OllamaProvider,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"KEYRING_SERVICE",
|
|
54
|
+
"PROVIDERS",
|
|
55
|
+
"AnthropicProvider",
|
|
56
|
+
"GeminiProvider",
|
|
57
|
+
"LLMAssistant",
|
|
58
|
+
"LLMResult",
|
|
59
|
+
"OllamaProvider",
|
|
60
|
+
"OpenAIProvider",
|
|
61
|
+
"Provider",
|
|
62
|
+
"build_prompt",
|
|
63
|
+
"delete_api_key",
|
|
64
|
+
"extract_json",
|
|
65
|
+
"get_api_key",
|
|
66
|
+
"get_provider",
|
|
67
|
+
"redact",
|
|
68
|
+
"sample_rows_for",
|
|
69
|
+
"set_api_key",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_provider(name: str, **kwargs: Any) -> Provider:
|
|
74
|
+
"""Instantiate a provider by name.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ProviderNotConfigured: If ``name`` is not a known provider.
|
|
78
|
+
"""
|
|
79
|
+
cls = PROVIDERS.get(name.lower().strip())
|
|
80
|
+
if cls is None:
|
|
81
|
+
raise ProviderNotConfigured(
|
|
82
|
+
f"Unknown LLM provider {name!r}.",
|
|
83
|
+
hint=f"Available providers: {', '.join(PROVIDERS)}.",
|
|
84
|
+
)
|
|
85
|
+
return cls(**kwargs)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class LLMAssistant:
|
|
89
|
+
"""Optional LLM help, with the local engine always underneath.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
provider: A configured provider, or ``None`` for local-only operation.
|
|
93
|
+
consent: Whether the user has agreed to send schema and statistics to a remote service.
|
|
94
|
+
Required before any provider that leaves the machine is used. Ollama does not need it.
|
|
95
|
+
|
|
96
|
+
Example:
|
|
97
|
+
>>> assistant = LLMAssistant(get_provider("ollama"), consent=True)
|
|
98
|
+
>>> question = "revenue by region over time"
|
|
99
|
+
>>> result = assistant.from_question(profile, question) # doctest: +SKIP
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, provider: Provider | None = None, *, consent: bool = False) -> None:
|
|
103
|
+
self.provider = provider
|
|
104
|
+
self.consent = consent
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------ state
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def available(self) -> bool:
|
|
110
|
+
"""Whether a request could be made right now — configured, and consented to if remote."""
|
|
111
|
+
if self.provider is None:
|
|
112
|
+
return False
|
|
113
|
+
if self.provider.sends_data_off_machine and not self.consent:
|
|
114
|
+
return False
|
|
115
|
+
return self.provider.is_configured()
|
|
116
|
+
|
|
117
|
+
def consent_prompt(self) -> str | None:
|
|
118
|
+
"""The text to show before the first remote request, or ``None`` if none is needed."""
|
|
119
|
+
if self.provider is None or not self.provider.sends_data_off_machine:
|
|
120
|
+
return None
|
|
121
|
+
return (
|
|
122
|
+
f"PlotaViz will send this dataset's column names, summary statistics, and up to "
|
|
123
|
+
f"{5} sample rows to {self.provider.name}. The dataset itself is never uploaded.\n\n"
|
|
124
|
+
"To keep everything on this machine, use the Ollama provider instead."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------ jobs
|
|
128
|
+
|
|
129
|
+
def from_question(
|
|
130
|
+
self,
|
|
131
|
+
profile: DatasetProfile,
|
|
132
|
+
question: str,
|
|
133
|
+
*,
|
|
134
|
+
sample_rows: list[dict[str, Any]] | None = None,
|
|
135
|
+
) -> LLMResult:
|
|
136
|
+
"""Turn a natural-language request into a validated chart spec.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
LLMError: If no provider is available, or the request fails. There is no local
|
|
140
|
+
fallback for this job — a free-text question genuinely needs a model — so the
|
|
141
|
+
caller should show the error and leave the current chart alone.
|
|
142
|
+
"""
|
|
143
|
+
if self.provider is None:
|
|
144
|
+
raise ProviderNotConfigured(
|
|
145
|
+
"The natural-language query bar needs an LLM provider.",
|
|
146
|
+
hint=(
|
|
147
|
+
"Configure one in Settings → LLM provider. For a fully local setup, install "
|
|
148
|
+
"Ollama and pull a model. The chart recommendations work without any of this."
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
if self.provider.sends_data_off_machine and not self.consent:
|
|
152
|
+
raise LLMError(
|
|
153
|
+
"Sending data to a remote provider has not been approved for this session.",
|
|
154
|
+
hint=(
|
|
155
|
+
"Accept the consent prompt in Settings, or switch to the local Ollama provider."
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
prompt_rows = sample_rows or []
|
|
160
|
+
result = self.provider.suggest_chart(profile, question=question)
|
|
161
|
+
logger.debug(
|
|
162
|
+
"NL query answered by %s/%s with %d sample rows",
|
|
163
|
+
result.provider,
|
|
164
|
+
result.model,
|
|
165
|
+
len(prompt_rows),
|
|
166
|
+
)
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
def refine(
|
|
170
|
+
self,
|
|
171
|
+
profile: DatasetProfile,
|
|
172
|
+
candidates: list[ChartSpec],
|
|
173
|
+
*,
|
|
174
|
+
fallback: ChartSpec | None = None,
|
|
175
|
+
) -> ChartSpec:
|
|
176
|
+
"""Ask for a second opinion on an ambiguous ranking, falling back silently.
|
|
177
|
+
|
|
178
|
+
This is the failure-tolerant path: a timeout, a bad key, a malformed reply, or no
|
|
179
|
+
provider at all all end the same way — with the local engine's answer. The user's chart
|
|
180
|
+
appears either way.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
profile: The dataset profile.
|
|
184
|
+
candidates: The local engine's ranked candidates.
|
|
185
|
+
fallback: Spec to return on any failure. Defaults to the top candidate.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Either the model's validated spec or the fallback.
|
|
189
|
+
"""
|
|
190
|
+
default = fallback or (candidates[0] if candidates else None)
|
|
191
|
+
if default is None:
|
|
192
|
+
raise LLMError("There are no candidate charts to refine.")
|
|
193
|
+
if not self.available:
|
|
194
|
+
return default
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
assert self.provider is not None # narrowed by self.available
|
|
198
|
+
result = self.provider.suggest_chart(profile, candidates=candidates)
|
|
199
|
+
except LLMError as exc:
|
|
200
|
+
logger.info("LLM refinement failed, using the local ranking: %s", redact(str(exc)))
|
|
201
|
+
return default
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
logger.warning("Unexpected LLM error, using the local ranking: %s", redact(str(exc)))
|
|
204
|
+
return default
|
|
205
|
+
|
|
206
|
+
spec = result.spec
|
|
207
|
+
if not spec.why:
|
|
208
|
+
spec.why = f"Suggested by {result.provider} ({result.model})."
|
|
209
|
+
else:
|
|
210
|
+
spec.why = f"{spec.why} (Suggested by {result.provider}.)"
|
|
211
|
+
return spec
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Anthropic (Claude) provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..errors import LLMError
|
|
6
|
+
from .base import Provider, redact
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AnthropicProvider(Provider):
|
|
10
|
+
"""Talks to the Anthropic Messages API.
|
|
11
|
+
|
|
12
|
+
Requires the ``anthropic`` package, which is an optional extra:
|
|
13
|
+
``pip install "plotaviz[llm]"``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
name = "anthropic"
|
|
17
|
+
default_model = "claude-sonnet-4-5"
|
|
18
|
+
available_models = (
|
|
19
|
+
"claude-opus-4-5",
|
|
20
|
+
"claude-sonnet-4-5",
|
|
21
|
+
"claude-haiku-4-5",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def complete(self, system: str, user: str, *, timeout: float) -> str:
|
|
25
|
+
"""Send one message and return the text of the reply."""
|
|
26
|
+
try:
|
|
27
|
+
import anthropic
|
|
28
|
+
except ImportError as exc:
|
|
29
|
+
raise LLMError(
|
|
30
|
+
"The Anthropic provider needs the anthropic package.",
|
|
31
|
+
hint='Install it with: pip install "plotaviz[llm]"',
|
|
32
|
+
) from exc
|
|
33
|
+
|
|
34
|
+
client = anthropic.Anthropic(api_key=self.require_key(), timeout=timeout)
|
|
35
|
+
try:
|
|
36
|
+
message = client.messages.create(
|
|
37
|
+
model=self.model,
|
|
38
|
+
max_tokens=1024,
|
|
39
|
+
system=system,
|
|
40
|
+
messages=[{"role": "user", "content": user}],
|
|
41
|
+
)
|
|
42
|
+
except anthropic.AuthenticationError as exc:
|
|
43
|
+
raise LLMError(
|
|
44
|
+
"Anthropic rejected the API key.",
|
|
45
|
+
hint="Check the key in Settings → LLM provider.",
|
|
46
|
+
) from exc
|
|
47
|
+
except anthropic.RateLimitError as exc:
|
|
48
|
+
raise LLMError(
|
|
49
|
+
"Anthropic is rate limiting this key.", hint="Wait a moment and try again."
|
|
50
|
+
) from exc
|
|
51
|
+
except anthropic.APITimeoutError as exc:
|
|
52
|
+
raise LLMError(
|
|
53
|
+
f"Anthropic did not respond within {timeout:.0f} seconds.",
|
|
54
|
+
hint="PlotaViz will use its own rules engine instead.",
|
|
55
|
+
) from exc
|
|
56
|
+
except anthropic.APIError as exc:
|
|
57
|
+
raise LLMError("The Anthropic request failed.", hint=redact(str(exc))) from exc
|
|
58
|
+
|
|
59
|
+
return "".join(
|
|
60
|
+
block.text for block in message.content if getattr(block, "type", "") == "text"
|
|
61
|
+
)
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Provider abstraction and the prompt/validation machinery around it.
|
|
2
|
+
|
|
3
|
+
A provider's entire job is to turn two strings into one string. Everything else — building the
|
|
4
|
+
prompt, deciding what data is safe to send, extracting JSON from a chatty reply, validating the
|
|
5
|
+
returned spec against the real schema — happens here, once, so every provider behaves identically
|
|
6
|
+
and a new one is about thirty lines.
|
|
7
|
+
|
|
8
|
+
Two rules are structural, not stylistic:
|
|
9
|
+
|
|
10
|
+
**The model returns a chart spec, never code.** PlotaViz does not execute anything a model
|
|
11
|
+
produces. The reply is parsed as JSON and validated against the dataframe's actual columns; an
|
|
12
|
+
unknown column is a rejection with a clear message, not a traceback.
|
|
13
|
+
|
|
14
|
+
**The model sees schema, statistics, and a few sample rows.** Never the full dataset. This is a
|
|
15
|
+
privacy boundary and a token-cost boundary at the same time.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
from abc import ABC, abstractmethod
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from ..errors import LLMError, ProviderNotConfigured
|
|
27
|
+
from ..profiler import DatasetProfile
|
|
28
|
+
from ..spec import CHART_TYPES, ChartSpec
|
|
29
|
+
|
|
30
|
+
#: Service name used for keyring entries. One entry per provider.
|
|
31
|
+
KEYRING_SERVICE = "plotaviz"
|
|
32
|
+
|
|
33
|
+
#: Default per-request timeout in seconds. LLM latency must never hang the UI.
|
|
34
|
+
DEFAULT_TIMEOUT = 30.0
|
|
35
|
+
|
|
36
|
+
#: Sample rows included in a prompt. Small on purpose.
|
|
37
|
+
SAMPLE_ROWS = 5
|
|
38
|
+
|
|
39
|
+
_SYSTEM_PROMPT = f"""You are the chart-selection assistant inside PlotaViz, a desktop data \
|
|
40
|
+
visualization tool.
|
|
41
|
+
|
|
42
|
+
You receive a dataset's schema and summary statistics — never the dataset itself. You reply with \
|
|
43
|
+
a single JSON object describing which chart to draw. You never write code, never write prose \
|
|
44
|
+
outside the JSON, and never invent column names.
|
|
45
|
+
|
|
46
|
+
Reply with exactly this shape:
|
|
47
|
+
|
|
48
|
+
{{
|
|
49
|
+
"chart": one of {list(CHART_TYPES)},
|
|
50
|
+
"x": column name or null,
|
|
51
|
+
"y": column name or null,
|
|
52
|
+
"color": column name or null,
|
|
53
|
+
"agg": one of ["sum","mean","median","min","max","count","nunique"] or null,
|
|
54
|
+
"title": short human title or null,
|
|
55
|
+
"options": {{}},
|
|
56
|
+
"why": one or two sentences explaining the choice in plain language
|
|
57
|
+
}}
|
|
58
|
+
|
|
59
|
+
Rules:
|
|
60
|
+
- Every column name you use MUST appear in the provided schema, spelled exactly.
|
|
61
|
+
- Use "agg" whenever y would otherwise have several values per x.
|
|
62
|
+
- Prefer readable charts: do not put a 500-category column on a bar axis.
|
|
63
|
+
- Columns marked "is_identifier": true are keys, not measures. Do not plot them.
|
|
64
|
+
- Respond with the JSON object alone. No markdown fences, no commentary.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class LLMResult:
|
|
70
|
+
"""What the LLM layer returns to the app.
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
spec: The validated chart spec.
|
|
74
|
+
narrative: Optional insight text the model offered alongside the choice.
|
|
75
|
+
provider: Which provider answered.
|
|
76
|
+
model: Which model answered.
|
|
77
|
+
raw: The raw reply, kept for the "show me what it said" affordance.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
spec: ChartSpec
|
|
81
|
+
narrative: str = ""
|
|
82
|
+
provider: str = ""
|
|
83
|
+
model: str = ""
|
|
84
|
+
raw: str = ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Provider(ABC):
|
|
88
|
+
"""Base class for an LLM provider.
|
|
89
|
+
|
|
90
|
+
Subclasses implement :meth:`complete` and declare :attr:`name` and :attr:`default_model`.
|
|
91
|
+
Everything else is inherited.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
model: Model identifier. Falls back to :attr:`default_model`.
|
|
95
|
+
api_key: Explicit key. When omitted, the key is read from the OS keyring.
|
|
96
|
+
timeout: Per-request timeout in seconds.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
#: Identifier used in settings and keyring entries.
|
|
100
|
+
name: str = "base"
|
|
101
|
+
|
|
102
|
+
#: Model used when the user has not chosen one.
|
|
103
|
+
default_model: str = ""
|
|
104
|
+
|
|
105
|
+
#: Models offered in the settings dialog.
|
|
106
|
+
available_models: tuple[str, ...] = ()
|
|
107
|
+
|
|
108
|
+
#: Whether this provider sends data off the machine. False for local runtimes.
|
|
109
|
+
sends_data_off_machine: bool = True
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
model: str | None = None,
|
|
114
|
+
api_key: str | None = None,
|
|
115
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
116
|
+
) -> None:
|
|
117
|
+
self.model = model or self.default_model
|
|
118
|
+
self._api_key = api_key
|
|
119
|
+
self.timeout = timeout
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------ credentials
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def api_key(self) -> str | None:
|
|
125
|
+
"""The API key, from the constructor or the OS keyring.
|
|
126
|
+
|
|
127
|
+
Keys are never read from environment variables or config files by default — the keyring
|
|
128
|
+
is the one storage location, so there is one place to audit.
|
|
129
|
+
"""
|
|
130
|
+
if self._api_key:
|
|
131
|
+
return self._api_key
|
|
132
|
+
return get_api_key(self.name)
|
|
133
|
+
|
|
134
|
+
def require_key(self) -> str:
|
|
135
|
+
"""Return the API key or explain how to set one.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
ProviderNotConfigured: If no key is stored.
|
|
139
|
+
"""
|
|
140
|
+
key = self.api_key
|
|
141
|
+
if not key:
|
|
142
|
+
raise ProviderNotConfigured(
|
|
143
|
+
f"No API key is stored for {self.name}.",
|
|
144
|
+
hint="Add one in Settings → LLM provider. Keys are kept in your OS keychain.",
|
|
145
|
+
)
|
|
146
|
+
return key
|
|
147
|
+
|
|
148
|
+
def is_configured(self) -> bool:
|
|
149
|
+
"""Whether this provider could make a request right now."""
|
|
150
|
+
return bool(self.api_key)
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------ contract
|
|
153
|
+
|
|
154
|
+
@abstractmethod
|
|
155
|
+
def complete(self, system: str, user: str, *, timeout: float) -> str:
|
|
156
|
+
"""Send one prompt and return the raw text reply.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
system: System prompt.
|
|
160
|
+
user: User message.
|
|
161
|
+
timeout: Seconds to wait before giving up.
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
LLMError: On any transport, auth, or API failure.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------ tasks
|
|
168
|
+
|
|
169
|
+
def suggest_chart(
|
|
170
|
+
self,
|
|
171
|
+
profile: DatasetProfile,
|
|
172
|
+
*,
|
|
173
|
+
question: str | None = None,
|
|
174
|
+
candidates: list[ChartSpec] | None = None,
|
|
175
|
+
) -> LLMResult:
|
|
176
|
+
"""Ask the model for a chart spec.
|
|
177
|
+
|
|
178
|
+
Serves both jobs the LLM layer exists for: with ``question`` it is natural-language →
|
|
179
|
+
spec; without one it is a second opinion on an ambiguous ranking.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
profile: The dataset profile. Only its schema summary is sent.
|
|
183
|
+
question: The user's natural-language request, if any.
|
|
184
|
+
candidates: The local engine's top candidates, sent as context so the model can agree
|
|
185
|
+
with a good ranking rather than inventing something worse.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
A validated :class:`LLMResult`.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
LLMError: If the request fails or the reply is not a usable spec. Callers are
|
|
192
|
+
expected to catch this and fall back to the local engine.
|
|
193
|
+
"""
|
|
194
|
+
prompt = build_prompt(profile, question=question, candidates=candidates)
|
|
195
|
+
raw = self.complete(_SYSTEM_PROMPT, prompt, timeout=self.timeout)
|
|
196
|
+
payload = extract_json(raw)
|
|
197
|
+
spec = ChartSpec.from_dict(payload)
|
|
198
|
+
spec.source = "llm"
|
|
199
|
+
spec.validate(list(profile.columns))
|
|
200
|
+
return LLMResult(
|
|
201
|
+
spec=spec,
|
|
202
|
+
narrative=str(payload.get("narrative") or payload.get("insight") or ""),
|
|
203
|
+
provider=self.name,
|
|
204
|
+
model=self.model,
|
|
205
|
+
raw=raw,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def __repr__(self) -> str:
|
|
209
|
+
return f"{type(self).__name__}(model={self.model!r})"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------- prompts
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def build_prompt(
|
|
216
|
+
profile: DatasetProfile,
|
|
217
|
+
*,
|
|
218
|
+
question: str | None = None,
|
|
219
|
+
candidates: list[ChartSpec] | None = None,
|
|
220
|
+
sample_rows: list[dict[str, Any]] | None = None,
|
|
221
|
+
) -> str:
|
|
222
|
+
"""Assemble the user message.
|
|
223
|
+
|
|
224
|
+
Contains the schema, per-column statistics, at most :data:`SAMPLE_ROWS` example rows, and the
|
|
225
|
+
local engine's candidates. Deliberately does not contain the dataset.
|
|
226
|
+
"""
|
|
227
|
+
summary = profile.schema_summary()
|
|
228
|
+
parts = [
|
|
229
|
+
"Dataset schema and summary statistics:",
|
|
230
|
+
json.dumps(summary, indent=2, default=str),
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
if sample_rows:
|
|
234
|
+
parts += [
|
|
235
|
+
"",
|
|
236
|
+
f"A sample of {min(len(sample_rows), SAMPLE_ROWS)} rows (illustrative only):",
|
|
237
|
+
json.dumps(sample_rows[:SAMPLE_ROWS], indent=2, default=str),
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
if candidates:
|
|
241
|
+
ranked = [
|
|
242
|
+
{"chart": c.chart, "x": c.x, "y": c.y, "color": c.color, "score": round(c.score, 3)}
|
|
243
|
+
for c in candidates[:5]
|
|
244
|
+
]
|
|
245
|
+
parts += [
|
|
246
|
+
"",
|
|
247
|
+
"PlotaViz's own rules engine ranked these candidates. Their scores were close, "
|
|
248
|
+
"which is why you are being asked:",
|
|
249
|
+
json.dumps(ranked, indent=2),
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
if question:
|
|
253
|
+
parts += [
|
|
254
|
+
"",
|
|
255
|
+
f"The user asked for: {question!r}",
|
|
256
|
+
"",
|
|
257
|
+
"Return the chart spec that answers it.",
|
|
258
|
+
]
|
|
259
|
+
else:
|
|
260
|
+
parts += ["", "Return the single chart spec that best reveals what is in this dataset."]
|
|
261
|
+
|
|
262
|
+
return "\n".join(parts)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def sample_rows_for(df: Any, n: int = SAMPLE_ROWS) -> list[dict[str, Any]]:
|
|
266
|
+
"""Take a few example rows in a JSON-safe form.
|
|
267
|
+
|
|
268
|
+
Kept tiny by design — enough for the model to see value formats, not enough to constitute
|
|
269
|
+
sending someone's data anywhere.
|
|
270
|
+
"""
|
|
271
|
+
try:
|
|
272
|
+
head = df.head(n)
|
|
273
|
+
return json.loads(head.to_json(orient="records", date_format="iso")) or []
|
|
274
|
+
except Exception:
|
|
275
|
+
return []
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def extract_json(text: str) -> dict[str, Any]:
|
|
279
|
+
"""Pull the JSON object out of a model reply.
|
|
280
|
+
|
|
281
|
+
Models add markdown fences and preambles no matter how firmly they are told not to, so this
|
|
282
|
+
tolerates both rather than failing on formatting.
|
|
283
|
+
|
|
284
|
+
Raises:
|
|
285
|
+
LLMError: If no JSON object can be found or parsed.
|
|
286
|
+
"""
|
|
287
|
+
if not text or not text.strip():
|
|
288
|
+
raise LLMError("The model returned an empty response.")
|
|
289
|
+
|
|
290
|
+
cleaned = text.strip()
|
|
291
|
+
fenced = re.search(r"```(?:json)?\s*(.+?)\s*```", cleaned, re.DOTALL)
|
|
292
|
+
if fenced:
|
|
293
|
+
cleaned = fenced.group(1).strip()
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
payload = json.loads(cleaned)
|
|
297
|
+
except json.JSONDecodeError:
|
|
298
|
+
start, end = cleaned.find("{"), cleaned.rfind("}")
|
|
299
|
+
if start == -1 or end <= start:
|
|
300
|
+
raise LLMError(
|
|
301
|
+
"The model's reply did not contain a chart specification.",
|
|
302
|
+
hint="Try rephrasing, or use the ranked recommendations from the local engine.",
|
|
303
|
+
) from None
|
|
304
|
+
try:
|
|
305
|
+
payload = json.loads(cleaned[start : end + 1])
|
|
306
|
+
except json.JSONDecodeError as exc:
|
|
307
|
+
raise LLMError("The model's reply was not valid JSON.", hint=str(exc)) from exc
|
|
308
|
+
|
|
309
|
+
if not isinstance(payload, dict):
|
|
310
|
+
raise LLMError("The model returned JSON, but not a chart specification object.")
|
|
311
|
+
return payload
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ---------------------------------------------------------------------------- keyring
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def get_api_key(provider: str) -> str | None:
|
|
318
|
+
"""Read a provider's API key from the OS keyring.
|
|
319
|
+
|
|
320
|
+
Returns ``None`` when nothing is stored or no keyring backend is available — a missing key is
|
|
321
|
+
an ordinary state, since PlotaViz works fully offline.
|
|
322
|
+
"""
|
|
323
|
+
try:
|
|
324
|
+
import keyring
|
|
325
|
+
|
|
326
|
+
return keyring.get_password(KEYRING_SERVICE, provider)
|
|
327
|
+
except Exception:
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def set_api_key(provider: str, key: str) -> None:
|
|
332
|
+
"""Store a provider's API key in the OS keyring.
|
|
333
|
+
|
|
334
|
+
Raises:
|
|
335
|
+
LLMError: If no keyring backend is available. PlotaViz does not fall back to writing the
|
|
336
|
+
key to a file — that is exactly the failure mode the keyring exists to prevent.
|
|
337
|
+
"""
|
|
338
|
+
try:
|
|
339
|
+
import keyring
|
|
340
|
+
|
|
341
|
+
keyring.set_password(KEYRING_SERVICE, provider, key)
|
|
342
|
+
except Exception as exc:
|
|
343
|
+
raise LLMError(
|
|
344
|
+
"Could not store the API key in your system keychain.",
|
|
345
|
+
hint=(
|
|
346
|
+
"PlotaViz will not write keys to disk in plain text. On Linux, install a Secret "
|
|
347
|
+
f"Service backend such as gnome-keyring or kwallet. Underlying error: {exc}"
|
|
348
|
+
),
|
|
349
|
+
) from exc
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def delete_api_key(provider: str) -> None:
|
|
353
|
+
"""Remove a provider's stored API key. Missing entries are not an error."""
|
|
354
|
+
try:
|
|
355
|
+
import keyring
|
|
356
|
+
|
|
357
|
+
keyring.delete_password(KEYRING_SERVICE, provider)
|
|
358
|
+
except Exception:
|
|
359
|
+
pass
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def redact(text: str) -> str:
|
|
363
|
+
"""Mask anything that looks like an API key before it reaches a log or an error dialog."""
|
|
364
|
+
patterns = (
|
|
365
|
+
r"sk-ant-[A-Za-z0-9\-_]{8,}",
|
|
366
|
+
r"sk-[A-Za-z0-9]{16,}",
|
|
367
|
+
r"AIza[0-9A-Za-z\-_]{20,}",
|
|
368
|
+
r"Bearer\s+[A-Za-z0-9\-._~+/]{16,}",
|
|
369
|
+
)
|
|
370
|
+
out = text
|
|
371
|
+
for pattern in patterns:
|
|
372
|
+
out = re.sub(pattern, "[redacted]", out)
|
|
373
|
+
return out
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Google Gemini provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..errors import LLMError
|
|
6
|
+
from .base import Provider, redact
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GeminiProvider(Provider):
|
|
10
|
+
"""Talks to the Google Gemini API.
|
|
11
|
+
|
|
12
|
+
Requires the ``google-genai`` package: ``pip install "plotaviz[llm]"``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
name = "gemini"
|
|
16
|
+
default_model = "gemini-2.0-flash"
|
|
17
|
+
available_models = ("gemini-2.5-pro", "gemini-2.0-flash", "gemini-2.0-flash-lite")
|
|
18
|
+
|
|
19
|
+
def complete(self, system: str, user: str, *, timeout: float) -> str:
|
|
20
|
+
"""Send one message and return the text of the reply."""
|
|
21
|
+
try:
|
|
22
|
+
from google import genai
|
|
23
|
+
from google.genai import types
|
|
24
|
+
except ImportError as exc:
|
|
25
|
+
raise LLMError(
|
|
26
|
+
"The Gemini provider needs the google-genai package.",
|
|
27
|
+
hint='Install it with: pip install "plotaviz[llm]"',
|
|
28
|
+
) from exc
|
|
29
|
+
|
|
30
|
+
client = genai.Client(api_key=self.require_key())
|
|
31
|
+
try:
|
|
32
|
+
response = client.models.generate_content(
|
|
33
|
+
model=self.model,
|
|
34
|
+
contents=user,
|
|
35
|
+
config=types.GenerateContentConfig(
|
|
36
|
+
system_instruction=system,
|
|
37
|
+
response_mime_type="application/json",
|
|
38
|
+
max_output_tokens=1024,
|
|
39
|
+
http_options=types.HttpOptions(timeout=int(timeout * 1000)),
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
message = str(exc)
|
|
44
|
+
if "API key" in message or "PERMISSION_DENIED" in message:
|
|
45
|
+
raise LLMError(
|
|
46
|
+
"Google rejected the API key.",
|
|
47
|
+
hint="Check the key in Settings → LLM provider.",
|
|
48
|
+
) from exc
|
|
49
|
+
raise LLMError("The Gemini request failed.", hint=redact(message)) from exc
|
|
50
|
+
|
|
51
|
+
return response.text or ""
|