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,219 @@
|
|
|
1
|
+
"""The natural-language query bar.
|
|
2
|
+
|
|
3
|
+
Type "revenue by region over time", get a chart. The important design decision is what happens
|
|
4
|
+
in between: the model returns a **chart spec**, PlotaViz validates it against the real schema,
|
|
5
|
+
and then *shows the user the spec it interpreted* before drawing. The mapping is never opaque,
|
|
6
|
+
and it stays editable — if the model picked the wrong column, that is one dropdown away from
|
|
7
|
+
fixed rather than a reason to give up on the feature.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from PySide6.QtCore import Signal
|
|
13
|
+
from PySide6.QtWidgets import (
|
|
14
|
+
QComboBox,
|
|
15
|
+
QFrame,
|
|
16
|
+
QHBoxLayout,
|
|
17
|
+
QLabel,
|
|
18
|
+
QLineEdit,
|
|
19
|
+
QProgressBar,
|
|
20
|
+
QPushButton,
|
|
21
|
+
QVBoxLayout,
|
|
22
|
+
QWidget,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from ..core.spec import AGGREGATIONS, CHART_TYPES, ChartSpec
|
|
26
|
+
|
|
27
|
+
#: Example prompts shown as placeholder text, rotated so the bar suggests what it can do.
|
|
28
|
+
EXAMPLES = (
|
|
29
|
+
"revenue by region over time",
|
|
30
|
+
"how does price relate to size?",
|
|
31
|
+
"distribution of response times",
|
|
32
|
+
"average score per department",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NLQueryBar(QWidget):
|
|
37
|
+
"""Free-text input plus an editable view of the interpreted chart spec.
|
|
38
|
+
|
|
39
|
+
Signals:
|
|
40
|
+
question_asked: Emitted with the user's text when they submit a query.
|
|
41
|
+
spec_edited: Emitted with a :class:`ChartSpec` when the user tweaks the interpretation.
|
|
42
|
+
settings_requested: Emitted when the user clicks through to configure a provider.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
question_asked = Signal(str)
|
|
46
|
+
spec_edited = Signal(object)
|
|
47
|
+
settings_requested = Signal()
|
|
48
|
+
|
|
49
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
50
|
+
super().__init__(parent)
|
|
51
|
+
self._columns: list[str] = []
|
|
52
|
+
self._spec: ChartSpec | None = None
|
|
53
|
+
self._updating = False
|
|
54
|
+
self._configured = False
|
|
55
|
+
|
|
56
|
+
layout = QVBoxLayout(self)
|
|
57
|
+
layout.setContentsMargins(8, 6, 8, 6)
|
|
58
|
+
layout.setSpacing(6)
|
|
59
|
+
|
|
60
|
+
# --- the question row
|
|
61
|
+
row = QHBoxLayout()
|
|
62
|
+
self.input = QLineEdit(self)
|
|
63
|
+
self.input.setPlaceholderText(f"Ask for a chart — e.g. {EXAMPLES[0]}")
|
|
64
|
+
self.input.setClearButtonEnabled(True)
|
|
65
|
+
self.input.returnPressed.connect(self._submit)
|
|
66
|
+
self.ask_button = QPushButton("Ask", self)
|
|
67
|
+
self.ask_button.clicked.connect(self._submit)
|
|
68
|
+
row.addWidget(self.input, stretch=1)
|
|
69
|
+
row.addWidget(self.ask_button)
|
|
70
|
+
layout.addLayout(row)
|
|
71
|
+
|
|
72
|
+
self.busy = QProgressBar(self)
|
|
73
|
+
self.busy.setRange(0, 0)
|
|
74
|
+
self.busy.setTextVisible(False)
|
|
75
|
+
self.busy.setMaximumHeight(3)
|
|
76
|
+
self.busy.hide()
|
|
77
|
+
layout.addWidget(self.busy)
|
|
78
|
+
|
|
79
|
+
self.status = QLabel("", self)
|
|
80
|
+
self.status.setWordWrap(True)
|
|
81
|
+
self.status.setStyleSheet("color: palette(mid); font-size: 11px;")
|
|
82
|
+
self.status.hide()
|
|
83
|
+
layout.addWidget(self.status)
|
|
84
|
+
|
|
85
|
+
# --- the interpreted spec, editable
|
|
86
|
+
self.interpretation = QFrame(self)
|
|
87
|
+
self.interpretation.setFrameShape(QFrame.Shape.StyledPanel)
|
|
88
|
+
interp_layout = QHBoxLayout(self.interpretation)
|
|
89
|
+
interp_layout.setContentsMargins(8, 4, 8, 4)
|
|
90
|
+
|
|
91
|
+
caption = QLabel("Interpreted as", self.interpretation)
|
|
92
|
+
caption.setStyleSheet("color: palette(mid); font-size: 11px;")
|
|
93
|
+
interp_layout.addWidget(caption)
|
|
94
|
+
|
|
95
|
+
self.chart_combo = self._combo(CHART_TYPES)
|
|
96
|
+
self.x_combo = self._combo([])
|
|
97
|
+
self.y_combo = self._combo([])
|
|
98
|
+
self.color_combo = self._combo([])
|
|
99
|
+
self.agg_combo = self._combo(("none", *AGGREGATIONS))
|
|
100
|
+
|
|
101
|
+
for label, combo in (
|
|
102
|
+
("chart", self.chart_combo),
|
|
103
|
+
("x", self.x_combo),
|
|
104
|
+
("y", self.y_combo),
|
|
105
|
+
("colour", self.color_combo),
|
|
106
|
+
("aggregate", self.agg_combo),
|
|
107
|
+
):
|
|
108
|
+
tag = QLabel(label, self.interpretation)
|
|
109
|
+
tag.setStyleSheet("color: palette(mid); font-size: 11px;")
|
|
110
|
+
interp_layout.addWidget(tag)
|
|
111
|
+
interp_layout.addWidget(combo)
|
|
112
|
+
|
|
113
|
+
interp_layout.addStretch(1)
|
|
114
|
+
self.interpretation.hide()
|
|
115
|
+
layout.addWidget(self.interpretation)
|
|
116
|
+
|
|
117
|
+
def _combo(self, values: object) -> QComboBox:
|
|
118
|
+
"""Build a combo box wired to emit an edited spec."""
|
|
119
|
+
combo = QComboBox(self)
|
|
120
|
+
combo.addItems([str(v) for v in values]) # type: ignore[arg-type]
|
|
121
|
+
combo.currentIndexChanged.connect(self._on_edited)
|
|
122
|
+
return combo
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------ state
|
|
125
|
+
|
|
126
|
+
def set_columns(self, columns: list[str]) -> None:
|
|
127
|
+
"""Populate the column dropdowns for the current dataset."""
|
|
128
|
+
self._columns = list(columns)
|
|
129
|
+
self._updating = True
|
|
130
|
+
for combo in (self.x_combo, self.y_combo, self.color_combo):
|
|
131
|
+
combo.clear()
|
|
132
|
+
combo.addItem("none")
|
|
133
|
+
combo.addItems(self._columns)
|
|
134
|
+
self._updating = False
|
|
135
|
+
|
|
136
|
+
def set_enabled_for_provider(self, configured: bool, provider_name: str = "") -> None:
|
|
137
|
+
"""Enable or disable the bar depending on whether an LLM provider is set up.
|
|
138
|
+
|
|
139
|
+
Disabled is the honest state when there is no provider — the rest of PlotaViz works
|
|
140
|
+
offline, but turning free text into a chart genuinely needs a model.
|
|
141
|
+
"""
|
|
142
|
+
self._configured = configured
|
|
143
|
+
self.input.setEnabled(configured)
|
|
144
|
+
self.ask_button.setEnabled(configured)
|
|
145
|
+
if configured:
|
|
146
|
+
provider = f" via {provider_name}" if provider_name else ""
|
|
147
|
+
self.input.setPlaceholderText(f"Ask for a chart{provider} — e.g. {EXAMPLES[0]}")
|
|
148
|
+
self.status.hide()
|
|
149
|
+
else:
|
|
150
|
+
self.input.setPlaceholderText("Set up an LLM provider to ask for charts in words")
|
|
151
|
+
self.show_status(
|
|
152
|
+
"No LLM provider is configured, so the query bar is off. Chart recommendations "
|
|
153
|
+
"work without one. Configure a provider in Settings, or install Ollama to keep "
|
|
154
|
+
"everything on this machine."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def set_busy(self, busy: bool) -> None:
|
|
158
|
+
"""Show or hide the indeterminate progress strip and lock the input while waiting."""
|
|
159
|
+
self.busy.setVisible(busy)
|
|
160
|
+
enabled = self._configured and not busy
|
|
161
|
+
self.input.setEnabled(enabled)
|
|
162
|
+
self.ask_button.setEnabled(enabled)
|
|
163
|
+
|
|
164
|
+
def show_status(self, message: str, *, error: bool = False) -> None:
|
|
165
|
+
"""Show a status or error line under the input."""
|
|
166
|
+
self.status.setText(message)
|
|
167
|
+
self.status.setStyleSheet(
|
|
168
|
+
"color: #C0392B; font-size: 11px;" if error else "color: palette(mid); font-size: 11px;"
|
|
169
|
+
)
|
|
170
|
+
self.status.show()
|
|
171
|
+
|
|
172
|
+
def show_spec(self, spec: ChartSpec) -> None:
|
|
173
|
+
"""Display the interpreted spec so the mapping is visible and editable."""
|
|
174
|
+
self._spec = spec
|
|
175
|
+
self._updating = True
|
|
176
|
+
|
|
177
|
+
self.chart_combo.setCurrentText(spec.chart)
|
|
178
|
+
self.x_combo.setCurrentText(spec.x or "none")
|
|
179
|
+
self.y_combo.setCurrentText(spec.y or "none")
|
|
180
|
+
self.color_combo.setCurrentText(spec.color or "none")
|
|
181
|
+
self.agg_combo.setCurrentText(spec.agg or "none")
|
|
182
|
+
|
|
183
|
+
self._updating = False
|
|
184
|
+
self.interpretation.show()
|
|
185
|
+
if spec.why:
|
|
186
|
+
self.show_status(spec.why)
|
|
187
|
+
|
|
188
|
+
def hide_interpretation(self) -> None:
|
|
189
|
+
"""Collapse the interpreted-spec row."""
|
|
190
|
+
self.interpretation.hide()
|
|
191
|
+
|
|
192
|
+
# ------------------------------------------------------------------ signals
|
|
193
|
+
|
|
194
|
+
def _submit(self) -> None:
|
|
195
|
+
"""Emit the user's question."""
|
|
196
|
+
question = self.input.text().strip()
|
|
197
|
+
if question:
|
|
198
|
+
self.question_asked.emit(question)
|
|
199
|
+
|
|
200
|
+
def _on_edited(self, *_: object) -> None:
|
|
201
|
+
"""Emit a spec reflecting the user's manual tweak to the interpretation."""
|
|
202
|
+
if self._updating or self._spec is None:
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
def value(combo: QComboBox) -> str | None:
|
|
206
|
+
text = combo.currentText()
|
|
207
|
+
return None if text == "none" else text
|
|
208
|
+
|
|
209
|
+
edited = self._spec.copy(
|
|
210
|
+
chart=self.chart_combo.currentText(),
|
|
211
|
+
x=value(self.x_combo),
|
|
212
|
+
y=value(self.y_combo),
|
|
213
|
+
color=value(self.color_combo),
|
|
214
|
+
agg=value(self.agg_combo),
|
|
215
|
+
source="user",
|
|
216
|
+
why="Adjusted by you.",
|
|
217
|
+
)
|
|
218
|
+
self._spec = edited
|
|
219
|
+
self.spec_edited.emit(edited)
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Settings — LLM provider, API keys, and performance thresholds.
|
|
2
|
+
|
|
3
|
+
The key handling here is the part worth reading. Keys go into the **OS keyring** and nowhere
|
|
4
|
+
else: not into a config file, not into the session, not into the window's own state after the
|
|
5
|
+
dialog closes. The field shows a masked placeholder when a key is already stored, so the user can
|
|
6
|
+
tell it is set without the value being displayed or re-read.
|
|
7
|
+
|
|
8
|
+
The consent checkbox is not decoration. Nothing is sent to a remote provider until it is ticked,
|
|
9
|
+
and the Ollama path skips the question entirely because nothing leaves the machine.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from PySide6.QtCore import QSettings
|
|
15
|
+
from PySide6.QtWidgets import (
|
|
16
|
+
QCheckBox,
|
|
17
|
+
QComboBox,
|
|
18
|
+
QDialog,
|
|
19
|
+
QDialogButtonBox,
|
|
20
|
+
QFormLayout,
|
|
21
|
+
QGroupBox,
|
|
22
|
+
QHBoxLayout,
|
|
23
|
+
QLabel,
|
|
24
|
+
QLineEdit,
|
|
25
|
+
QPushButton,
|
|
26
|
+
QSpinBox,
|
|
27
|
+
QVBoxLayout,
|
|
28
|
+
QWidget,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from ..core.llm import PROVIDERS, OllamaProvider, delete_api_key, get_api_key, set_api_key
|
|
32
|
+
from ..core.loader import DEFAULT_LARGE_FILE_MB
|
|
33
|
+
from ..core.plotter import MAX_POINTS
|
|
34
|
+
|
|
35
|
+
#: Placeholder shown when a key is already in the keyring. The real value is never displayed.
|
|
36
|
+
_STORED_PLACEHOLDER = "•" * 24
|
|
37
|
+
|
|
38
|
+
_PROVIDER_LABELS = {
|
|
39
|
+
"anthropic": "Anthropic (Claude)",
|
|
40
|
+
"openai": "OpenAI",
|
|
41
|
+
"gemini": "Google Gemini",
|
|
42
|
+
"ollama": "Ollama (local — nothing leaves this machine)",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SettingsDialog(QDialog):
|
|
47
|
+
"""Configures the LLM provider and performance thresholds.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
parent: Parent widget.
|
|
51
|
+
settings: The app's ``QSettings``. Non-secret preferences are stored here; keys are not.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, parent: QWidget | None = None, *, settings: QSettings | None = None) -> None:
|
|
55
|
+
super().__init__(parent)
|
|
56
|
+
self.setWindowTitle("PlotaViz settings")
|
|
57
|
+
self.setMinimumWidth(560)
|
|
58
|
+
self._settings = settings or QSettings("plotaviz", "plotaviz")
|
|
59
|
+
|
|
60
|
+
layout = QVBoxLayout(self)
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------ LLM
|
|
63
|
+
llm_box = QGroupBox("LLM assistance (optional)", self)
|
|
64
|
+
llm_form = QFormLayout(llm_box)
|
|
65
|
+
|
|
66
|
+
intro = QLabel(
|
|
67
|
+
"PlotaViz recommends charts on its own, offline, with no provider configured. "
|
|
68
|
+
"A model adds two things: a second opinion when the ranking is close, and the "
|
|
69
|
+
"natural-language query bar.",
|
|
70
|
+
llm_box,
|
|
71
|
+
)
|
|
72
|
+
intro.setWordWrap(True)
|
|
73
|
+
intro.setStyleSheet("color: palette(mid); font-size: 11px;")
|
|
74
|
+
llm_form.addRow(intro)
|
|
75
|
+
|
|
76
|
+
self.provider_combo = QComboBox(llm_box)
|
|
77
|
+
self.provider_combo.addItem("None — use the local engine only", "")
|
|
78
|
+
for name in PROVIDERS:
|
|
79
|
+
self.provider_combo.addItem(_PROVIDER_LABELS.get(name, name), name)
|
|
80
|
+
self.provider_combo.currentIndexChanged.connect(self._on_provider_changed)
|
|
81
|
+
llm_form.addRow("Provider", self.provider_combo)
|
|
82
|
+
|
|
83
|
+
self.model_combo = QComboBox(llm_box)
|
|
84
|
+
self.model_combo.setEditable(True)
|
|
85
|
+
llm_form.addRow("Model", self.model_combo)
|
|
86
|
+
|
|
87
|
+
key_row = QHBoxLayout()
|
|
88
|
+
self.key_edit = QLineEdit(llm_box)
|
|
89
|
+
self.key_edit.setEchoMode(QLineEdit.EchoMode.Password)
|
|
90
|
+
self.key_edit.setPlaceholderText("Paste an API key")
|
|
91
|
+
self.clear_key_button = QPushButton("Forget key", llm_box)
|
|
92
|
+
self.clear_key_button.clicked.connect(self._forget_key)
|
|
93
|
+
key_row.addWidget(self.key_edit, stretch=1)
|
|
94
|
+
key_row.addWidget(self.clear_key_button)
|
|
95
|
+
llm_form.addRow("API key", key_row)
|
|
96
|
+
|
|
97
|
+
key_note = QLabel(
|
|
98
|
+
"Keys are stored in your operating system's keychain, never in a config file, a "
|
|
99
|
+
"session file, or this project's folder.",
|
|
100
|
+
llm_box,
|
|
101
|
+
)
|
|
102
|
+
key_note.setWordWrap(True)
|
|
103
|
+
key_note.setStyleSheet("color: palette(mid); font-size: 11px;")
|
|
104
|
+
llm_form.addRow("", key_note)
|
|
105
|
+
|
|
106
|
+
self.host_edit = QLineEdit(llm_box)
|
|
107
|
+
self.host_edit.setPlaceholderText("http://localhost:11434")
|
|
108
|
+
llm_form.addRow("Ollama host", self.host_edit)
|
|
109
|
+
|
|
110
|
+
self.consent_check = QCheckBox(
|
|
111
|
+
"Allow sending column names, summary statistics, and up to 5 sample rows", llm_box
|
|
112
|
+
)
|
|
113
|
+
self.consent_check.setToolTip(
|
|
114
|
+
"The dataset itself is never uploaded. Only schema and statistics are sent, and only "
|
|
115
|
+
"when you ask for a suggestion."
|
|
116
|
+
)
|
|
117
|
+
llm_form.addRow("Consent", self.consent_check)
|
|
118
|
+
|
|
119
|
+
self.status_label = QLabel("", llm_box)
|
|
120
|
+
self.status_label.setWordWrap(True)
|
|
121
|
+
llm_form.addRow("", self.status_label)
|
|
122
|
+
|
|
123
|
+
test_button = QPushButton("Test connection", llm_box)
|
|
124
|
+
test_button.clicked.connect(self._test_connection)
|
|
125
|
+
llm_form.addRow("", test_button)
|
|
126
|
+
|
|
127
|
+
layout.addWidget(llm_box)
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------ performance
|
|
130
|
+
perf_box = QGroupBox("Performance", self)
|
|
131
|
+
perf_form = QFormLayout(perf_box)
|
|
132
|
+
|
|
133
|
+
self.large_file_spin = QSpinBox(perf_box)
|
|
134
|
+
self.large_file_spin.setRange(10, 20_000)
|
|
135
|
+
self.large_file_spin.setSingleStep(50)
|
|
136
|
+
self.large_file_spin.setSuffix(" MB")
|
|
137
|
+
self.large_file_spin.setToolTip(
|
|
138
|
+
"Files above this size are read with polars and profiled on a sample."
|
|
139
|
+
)
|
|
140
|
+
perf_form.addRow("Use the fast reader above", self.large_file_spin)
|
|
141
|
+
|
|
142
|
+
self.max_points_spin = QSpinBox(perf_box)
|
|
143
|
+
self.max_points_spin.setRange(1_000, 2_000_000)
|
|
144
|
+
self.max_points_spin.setSingleStep(10_000)
|
|
145
|
+
self.max_points_spin.setGroupSeparatorShown(True)
|
|
146
|
+
self.max_points_spin.setToolTip(
|
|
147
|
+
"Scatter and line charts above this many points are downsampled. Raising it past "
|
|
148
|
+
"about 100,000 will make the interactive view sluggish."
|
|
149
|
+
)
|
|
150
|
+
perf_form.addRow("Downsample charts above", self.max_points_spin)
|
|
151
|
+
|
|
152
|
+
self.static_check = QCheckBox(
|
|
153
|
+
"Always use the static renderer (no interactive view)", perf_box
|
|
154
|
+
)
|
|
155
|
+
self.static_check.setToolTip(
|
|
156
|
+
"Useful on machines without QtWebEngine, or when you prefer matplotlib output."
|
|
157
|
+
)
|
|
158
|
+
perf_form.addRow("Renderer", self.static_check)
|
|
159
|
+
|
|
160
|
+
layout.addWidget(perf_box)
|
|
161
|
+
|
|
162
|
+
buttons = QDialogButtonBox(
|
|
163
|
+
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel, self
|
|
164
|
+
)
|
|
165
|
+
buttons.accepted.connect(self._save)
|
|
166
|
+
buttons.rejected.connect(self.reject)
|
|
167
|
+
layout.addWidget(buttons)
|
|
168
|
+
|
|
169
|
+
self._load()
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------------ load / save
|
|
172
|
+
|
|
173
|
+
def _load(self) -> None:
|
|
174
|
+
"""Populate the dialog from QSettings and the keyring."""
|
|
175
|
+
provider = str(self._settings.value("llm/provider", "") or "")
|
|
176
|
+
index = self.provider_combo.findData(provider)
|
|
177
|
+
self.provider_combo.setCurrentIndex(max(0, index))
|
|
178
|
+
|
|
179
|
+
self.host_edit.setText(
|
|
180
|
+
str(self._settings.value("llm/ollama_host", "http://localhost:11434"))
|
|
181
|
+
)
|
|
182
|
+
self.consent_check.setChecked(self._settings.value("llm/consent", False, type=bool))
|
|
183
|
+
self.large_file_spin.setValue(
|
|
184
|
+
int(self._settings.value("performance/large_file_mb", DEFAULT_LARGE_FILE_MB))
|
|
185
|
+
)
|
|
186
|
+
self.max_points_spin.setValue(
|
|
187
|
+
int(self._settings.value("performance/max_points", MAX_POINTS))
|
|
188
|
+
)
|
|
189
|
+
self.static_check.setChecked(
|
|
190
|
+
self._settings.value("performance/prefer_static", False, type=bool)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
self._on_provider_changed()
|
|
194
|
+
model = str(self._settings.value("llm/model", "") or "")
|
|
195
|
+
if model:
|
|
196
|
+
self.model_combo.setCurrentText(model)
|
|
197
|
+
|
|
198
|
+
def _save(self) -> None:
|
|
199
|
+
"""Write settings, and put any newly entered key into the keyring."""
|
|
200
|
+
provider = str(self.provider_combo.currentData() or "")
|
|
201
|
+
self._settings.setValue("llm/provider", provider)
|
|
202
|
+
self._settings.setValue("llm/model", self.model_combo.currentText().strip())
|
|
203
|
+
self._settings.setValue("llm/ollama_host", self.host_edit.text().strip())
|
|
204
|
+
self._settings.setValue("llm/consent", self.consent_check.isChecked())
|
|
205
|
+
self._settings.setValue("performance/large_file_mb", self.large_file_spin.value())
|
|
206
|
+
self._settings.setValue("performance/max_points", self.max_points_spin.value())
|
|
207
|
+
self._settings.setValue("performance/prefer_static", self.static_check.isChecked())
|
|
208
|
+
|
|
209
|
+
key = self.key_edit.text().strip()
|
|
210
|
+
if provider and key and key != _STORED_PLACEHOLDER:
|
|
211
|
+
try:
|
|
212
|
+
set_api_key(provider, key)
|
|
213
|
+
except Exception as exc:
|
|
214
|
+
self.status_label.setText(str(exc))
|
|
215
|
+
self.status_label.setStyleSheet("color: #C0392B;")
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
self._settings.sync()
|
|
219
|
+
self.accept()
|
|
220
|
+
|
|
221
|
+
# ------------------------------------------------------------------ interaction
|
|
222
|
+
|
|
223
|
+
def _on_provider_changed(self, *_: object) -> None:
|
|
224
|
+
"""Update the model list, key field, and consent state for the chosen provider."""
|
|
225
|
+
provider = str(self.provider_combo.currentData() or "")
|
|
226
|
+
is_ollama = provider == "ollama"
|
|
227
|
+
needs_key = bool(provider) and not is_ollama
|
|
228
|
+
|
|
229
|
+
self.key_edit.setEnabled(needs_key)
|
|
230
|
+
self.clear_key_button.setEnabled(needs_key)
|
|
231
|
+
self.host_edit.setEnabled(is_ollama)
|
|
232
|
+
self.consent_check.setEnabled(needs_key)
|
|
233
|
+
|
|
234
|
+
self.model_combo.clear()
|
|
235
|
+
if provider:
|
|
236
|
+
cls = PROVIDERS[provider]
|
|
237
|
+
self.model_combo.addItems(list(cls.available_models))
|
|
238
|
+
self.model_combo.setCurrentText(cls.default_model)
|
|
239
|
+
|
|
240
|
+
if needs_key:
|
|
241
|
+
stored = get_api_key(provider)
|
|
242
|
+
self.key_edit.setText(_STORED_PLACEHOLDER if stored else "")
|
|
243
|
+
self.status_label.setText(
|
|
244
|
+
"A key is stored in your keychain." if stored else "No key stored yet."
|
|
245
|
+
)
|
|
246
|
+
self.status_label.setStyleSheet("color: palette(mid); font-size: 11px;")
|
|
247
|
+
elif is_ollama:
|
|
248
|
+
self.key_edit.clear()
|
|
249
|
+
self.consent_check.setChecked(True)
|
|
250
|
+
self.status_label.setText(
|
|
251
|
+
"Ollama runs locally. No API key and no network access are needed, and no data "
|
|
252
|
+
"leaves this machine."
|
|
253
|
+
)
|
|
254
|
+
self.status_label.setStyleSheet("color: #1F7A5C; font-size: 11px;")
|
|
255
|
+
else:
|
|
256
|
+
self.key_edit.clear()
|
|
257
|
+
self.status_label.setText(
|
|
258
|
+
"PlotaViz will use its own rules engine. Everything except the natural-language "
|
|
259
|
+
"query bar works normally."
|
|
260
|
+
)
|
|
261
|
+
self.status_label.setStyleSheet("color: palette(mid); font-size: 11px;")
|
|
262
|
+
|
|
263
|
+
def _forget_key(self) -> None:
|
|
264
|
+
"""Delete the stored key for the selected provider."""
|
|
265
|
+
provider = str(self.provider_combo.currentData() or "")
|
|
266
|
+
if not provider:
|
|
267
|
+
return
|
|
268
|
+
delete_api_key(provider)
|
|
269
|
+
self.key_edit.clear()
|
|
270
|
+
self.status_label.setText("Key removed from your keychain.")
|
|
271
|
+
self.status_label.setStyleSheet("color: palette(mid); font-size: 11px;")
|
|
272
|
+
|
|
273
|
+
def _test_connection(self) -> None:
|
|
274
|
+
"""Check the provider looks usable without sending any data."""
|
|
275
|
+
provider = str(self.provider_combo.currentData() or "")
|
|
276
|
+
if not provider:
|
|
277
|
+
self.status_label.setText(
|
|
278
|
+
"No provider selected — the local engine is always available."
|
|
279
|
+
)
|
|
280
|
+
self.status_label.setStyleSheet("color: palette(mid); font-size: 11px;")
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
if provider == "ollama":
|
|
284
|
+
client = OllamaProvider(host=self.host_edit.text().strip() or "http://localhost:11434")
|
|
285
|
+
if client.is_running():
|
|
286
|
+
models = client.list_models()
|
|
287
|
+
listed = ", ".join(models[:5]) if models else "no models pulled yet"
|
|
288
|
+
self.status_label.setText(f"Ollama is running. Models: {listed}")
|
|
289
|
+
self.status_label.setStyleSheet("color: #1F7A5C; font-size: 11px;")
|
|
290
|
+
else:
|
|
291
|
+
self.status_label.setText(
|
|
292
|
+
f"No Ollama server answered at {client.host}. Start it with `ollama serve`."
|
|
293
|
+
)
|
|
294
|
+
self.status_label.setStyleSheet("color: #C0392B; font-size: 11px;")
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
typed = self.key_edit.text().strip()
|
|
298
|
+
has_key = bool(typed and typed != _STORED_PLACEHOLDER) or bool(get_api_key(provider))
|
|
299
|
+
if has_key:
|
|
300
|
+
self.status_label.setText(
|
|
301
|
+
"A key is available. It is checked for real the first time you ask for a "
|
|
302
|
+
"suggestion."
|
|
303
|
+
)
|
|
304
|
+
self.status_label.setStyleSheet("color: #1F7A5C; font-size: 11px;")
|
|
305
|
+
else:
|
|
306
|
+
self.status_label.setText("No API key for this provider yet.")
|
|
307
|
+
self.status_label.setStyleSheet("color: #C0392B; font-size: 11px;")
|
|
308
|
+
|
|
309
|
+
# ------------------------------------------------------------------ results
|
|
310
|
+
|
|
311
|
+
def provider_name(self) -> str:
|
|
312
|
+
"""The provider the user selected, or an empty string for local-only."""
|
|
313
|
+
return str(self.provider_combo.currentData() or "")
|