sqlseed-web 0.2.4__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.
- sqlseed_web/AGENTS.md +106 -0
- sqlseed_web/__init__.py +24 -0
- sqlseed_web/__main__.py +8 -0
- sqlseed_web/_application.py +205 -0
- sqlseed_web/ai_settings.py +290 -0
- sqlseed_web/api.py +1102 -0
- sqlseed_web/app.py +26 -0
- sqlseed_web/managed_worker.py +184 -0
- sqlseed_web/operation_errors.py +42 -0
- sqlseed_web/plugin_environment.py +231 -0
- sqlseed_web/plugin_management.py +322 -0
- sqlseed_web/plugin_process.py +131 -0
- sqlseed_web/runtime_lifecycle.py +130 -0
- sqlseed_web/runtime_session.py +97 -0
- sqlseed_web/settings_environment.py +368 -0
- sqlseed_web/sqlite_target.py +86 -0
- sqlseed_web/state.py +348 -0
- sqlseed_web/static/AGENTS.md +180 -0
- sqlseed_web/static/ai.css +57 -0
- sqlseed_web/static/configs.css +50 -0
- sqlseed_web/static/date-picker.css +230 -0
- sqlseed_web/static/disclosure.css +149 -0
- sqlseed_web/static/graph-clarity.css +103 -0
- sqlseed_web/static/index.html +29 -0
- sqlseed_web/static/js/api.js +228 -0
- sqlseed_web/static/js/app.js +97 -0
- sqlseed_web/static/js/dropdown.js +432 -0
- sqlseed_web/static/js/filepicker.js +203 -0
- sqlseed_web/static/js/genform.js +1066 -0
- sqlseed_web/static/js/labels.js +195 -0
- sqlseed_web/static/js/pages/browse.js +209 -0
- sqlseed_web/static/js/pages/configs.js +424 -0
- sqlseed_web/static/js/pages/connect.js +332 -0
- sqlseed_web/static/js/pages/heal.js +395 -0
- sqlseed_web/static/js/pages/meta.js +110 -0
- sqlseed_web/static/js/pages/runs.js +293 -0
- sqlseed_web/static/js/pages/settings.js +942 -0
- sqlseed_web/static/js/pages/wizard.js +751 -0
- sqlseed_web/static/js/pages/workbench.js +3123 -0
- sqlseed_web/static/js/tree.js +126 -0
- sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
- sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
- sqlseed_web/static/js/workbench/ai-stream.js +116 -0
- sqlseed_web/static/js/workbench/ai.js +888 -0
- sqlseed_web/static/js/workbench/connection.js +508 -0
- sqlseed_web/static/js/workbench/date-picker.js +445 -0
- sqlseed_web/static/js/workbench/dependency-view.js +119 -0
- sqlseed_web/static/js/workbench/editor.js +1236 -0
- sqlseed_web/static/js/workbench/focus.js +11 -0
- sqlseed_web/static/js/workbench/graph-layout.js +332 -0
- sqlseed_web/static/js/workbench/graph.js +970 -0
- sqlseed_web/static/js/workbench/guidance.js +29 -0
- sqlseed_web/static/js/workbench/model.js +124 -0
- sqlseed_web/static/js/workbench/plugin-management.js +512 -0
- sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
- sqlseed_web/static/js/workbench/preview.js +572 -0
- sqlseed_web/static/js/workbench/provider-guide.js +33 -0
- sqlseed_web/static/js/workbench/recovery.js +28 -0
- sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
- sqlseed_web/static/js/workbench/session.js +174 -0
- sqlseed_web/static/js/workbench/table-data.js +186 -0
- sqlseed_web/static/js/workbench/ui.js +262 -0
- sqlseed_web/static/navigation.css +92 -0
- sqlseed_web/static/preview.css +29 -0
- sqlseed_web/static/runs.css +53 -0
- sqlseed_web/static/scrollbars.css +42 -0
- sqlseed_web/static/settings.css +108 -0
- sqlseed_web/static/style.css +3382 -0
- sqlseed_web/static/table-data.css +27 -0
- sqlseed_web/static/workbench.css +509 -0
- sqlseed_web/supervised_plugins.py +173 -0
- sqlseed_web/supervisor.py +238 -0
- sqlseed_web/workbench.py +381 -0
- sqlseed_web/workbench_ai.py +887 -0
- sqlseed_web/workbench_ai_relations.py +285 -0
- sqlseed_web/workbench_ai_stream.py +172 -0
- sqlseed_web/workbench_data.py +163 -0
- sqlseed_web/workbench_execution.py +199 -0
- sqlseed_web/workbench_runtime.py +1218 -0
- sqlseed_web/workbench_schema.py +277 -0
- sqlseed_web/workbench_store.py +458 -0
- sqlseed_web/worker_control.py +192 -0
- sqlseed_web-0.2.4.dist-info/METADATA +105 -0
- sqlseed_web-0.2.4.dist-info/RECORD +87 -0
- sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
- sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
- sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
sqlseed_web/api.py
ADDED
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
"""HTTP API for sqlseed-web.
|
|
2
|
+
|
|
3
|
+
Routers (all mounted under ``/api``):
|
|
4
|
+
|
|
5
|
+
- ``/api/meta`` — introspection: generators + param signatures, hooks,
|
|
6
|
+
providers, AI backend status. The "acceptance cockpit"
|
|
7
|
+
surface: counts must match the code (36 generators,
|
|
8
|
+
12 hooks).
|
|
9
|
+
- ``/api/connections`` — open/list/close databases; table listing.
|
|
10
|
+
- ``/api/connections/{id}/tables/{t}`` — schema (columns/FKs/indexes),
|
|
11
|
+
column mapping (the 9-level chain output per column).
|
|
12
|
+
- preview / fill / rows — generation execution and data browsing.
|
|
13
|
+
- ``/api/config`` — YAML <-> dict round-trip via core load_config.
|
|
14
|
+
- ``/api/connections/{id}/heal`` — self-heal laboratory:
|
|
15
|
+
validate (Layer 2), repair (Layer 3), auto-heal
|
|
16
|
+
(Layer 5, requires sqlseed-ai + LLM backend).
|
|
17
|
+
|
|
18
|
+
sqlseed-ai is an optional dependency: heal endpoints degrade to
|
|
19
|
+
``{"available": false, "reason": ...}`` when it is not installed.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import importlib
|
|
25
|
+
import inspect
|
|
26
|
+
from contextlib import closing
|
|
27
|
+
from dataclasses import asdict, is_dataclass
|
|
28
|
+
from http import HTTPStatus
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
import yaml
|
|
32
|
+
from fastapi import APIRouter, HTTPException
|
|
33
|
+
from pydantic import BaseModel
|
|
34
|
+
from sqlalchemy.exc import StatementError
|
|
35
|
+
from sqlseed._utils.logger import get_logger
|
|
36
|
+
from sqlseed._utils.sql_safe import quote_identifier, validate_table_name
|
|
37
|
+
from sqlseed.config.loader import load_config
|
|
38
|
+
from sqlseed.config.models import GeneratorConfig
|
|
39
|
+
from sqlseed.core.orchestrator import DataOrchestrator
|
|
40
|
+
from sqlseed.generators._dispatch import GeneratorDispatchMixin
|
|
41
|
+
|
|
42
|
+
from sqlseed_web.ai_settings import SettingsRequest, credential_snapshot, resolve_settings, set_session_preferences
|
|
43
|
+
from sqlseed_web.operation_errors import generation_errors
|
|
44
|
+
from sqlseed_web.runtime_lifecycle import start_background
|
|
45
|
+
from sqlseed_web.settings_environment import ai_import_failure, provider_availability, require_ai_available
|
|
46
|
+
from sqlseed_web.state import ConnectionBusyError, UnknownConnectionError, state
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
import httpx
|
|
50
|
+
|
|
51
|
+
logger = get_logger(__name__)
|
|
52
|
+
|
|
53
|
+
router = APIRouter(prefix="/api")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --------------------------------------------------------------------------
|
|
57
|
+
# Request/response models
|
|
58
|
+
# --------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ConnectRequest(BaseModel):
|
|
62
|
+
db_path: str | None = None
|
|
63
|
+
url: str | None = None
|
|
64
|
+
provider: str = "mimesis"
|
|
65
|
+
locale: str = "en_US"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PreviewRequest(BaseModel):
|
|
69
|
+
table: str
|
|
70
|
+
count: int = 5
|
|
71
|
+
columns: dict[str, Any] | None = None
|
|
72
|
+
seed: int | None = None
|
|
73
|
+
transform: str | None = None
|
|
74
|
+
enrich: bool = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class FillRequest(BaseModel):
|
|
78
|
+
table: str
|
|
79
|
+
count: int = 1000
|
|
80
|
+
columns: dict[str, Any] | None = None
|
|
81
|
+
seed: int | None = None
|
|
82
|
+
batch_size: int = 5000
|
|
83
|
+
clear_before: bool = False
|
|
84
|
+
enrich: bool = False
|
|
85
|
+
transform: str | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class YamlRequest(BaseModel):
|
|
89
|
+
yaml: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class HealValidateRequest(BaseModel):
|
|
93
|
+
yaml: str
|
|
94
|
+
dialect: str = "sqlite"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AutoHealRequest(BaseModel):
|
|
98
|
+
budget_seconds: float = 300.0
|
|
99
|
+
model: str | None = None
|
|
100
|
+
api_key: str | None = None
|
|
101
|
+
base_url: str | None = None
|
|
102
|
+
backend: str | None = None
|
|
103
|
+
timeout: float = 0.0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AIConfigRequest(BaseModel):
|
|
107
|
+
backend: str | None = None
|
|
108
|
+
model: str | None = None
|
|
109
|
+
api_key: str | None = None
|
|
110
|
+
base_url: str | None = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------------------------------
|
|
114
|
+
# Helpers
|
|
115
|
+
# --------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _conn_or_404(conn_id: str) -> DataOrchestrator:
|
|
119
|
+
try:
|
|
120
|
+
return state.get_connection(conn_id).orchestrator
|
|
121
|
+
except KeyError as exc:
|
|
122
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _error_detail(exc: Exception) -> str:
|
|
126
|
+
"""Describe a failure without SQLAlchemy's SQL and parameter dump."""
|
|
127
|
+
if isinstance(exc, StatementError) and exc.orig is not None:
|
|
128
|
+
return str(exc.orig)
|
|
129
|
+
return str(exc)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _serialize(value: Any) -> Any:
|
|
133
|
+
"""Make any core dataclass JSON-safe (dates/datetimes/bytes -> str)."""
|
|
134
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
135
|
+
return {k: _serialize(v) for k, v in asdict(value).items()}
|
|
136
|
+
if isinstance(value, dict):
|
|
137
|
+
return {k: _serialize(v) for k, v in value.items()}
|
|
138
|
+
if isinstance(value, (list, tuple, set)):
|
|
139
|
+
return [_serialize(v) for v in value]
|
|
140
|
+
if hasattr(value, "isoformat"):
|
|
141
|
+
return value.isoformat()
|
|
142
|
+
if isinstance(value, bytes):
|
|
143
|
+
return bytes.decode(value, "utf-8", errors="replace")
|
|
144
|
+
return value
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _yaml_to_config_dict(yaml_text: str) -> dict[str, Any]:
|
|
148
|
+
"""Parse YAML into a plain dict; empty input -> empty dict."""
|
|
149
|
+
if not (text := (yaml_text or "").strip()):
|
|
150
|
+
return {}
|
|
151
|
+
try:
|
|
152
|
+
parsed = yaml.safe_load(text)
|
|
153
|
+
except yaml.YAMLError as exc:
|
|
154
|
+
raise HTTPException(status_code=422, detail=f"invalid YAML: {exc}") from exc
|
|
155
|
+
if parsed is None:
|
|
156
|
+
return {}
|
|
157
|
+
if not isinstance(parsed, dict):
|
|
158
|
+
raise HTTPException(status_code=422, detail="YAML root must be a mapping")
|
|
159
|
+
return parsed
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _run_fill_job(conn_id: str, job_id: str, req: FillRequest) -> None:
|
|
163
|
+
"""Background-thread body for a fill job."""
|
|
164
|
+
with state.job_completion(job_id):
|
|
165
|
+
job = state.get_job(job_id)
|
|
166
|
+
orch: DataOrchestrator | None = None
|
|
167
|
+
try:
|
|
168
|
+
with state.connection_operation(conn_id, job_id=job_id) as conn:
|
|
169
|
+
orch = conn.orchestrator
|
|
170
|
+
job.rows_before = orch.get_row_count(req.table)
|
|
171
|
+
result = orch.fill_table(
|
|
172
|
+
req.table,
|
|
173
|
+
count=req.count,
|
|
174
|
+
columns=req.columns,
|
|
175
|
+
seed=req.seed,
|
|
176
|
+
batch_size=req.batch_size,
|
|
177
|
+
clear_before=req.clear_before,
|
|
178
|
+
enrich=req.enrich,
|
|
179
|
+
transform=req.transform,
|
|
180
|
+
)
|
|
181
|
+
payload = {
|
|
182
|
+
"rows_inserted": result.count,
|
|
183
|
+
"elapsed": result.elapsed,
|
|
184
|
+
"rows_per_second": result.rows_per_second,
|
|
185
|
+
"errors": result.errors,
|
|
186
|
+
"table": req.table,
|
|
187
|
+
"row_count_after": orch.get_row_count(req.table),
|
|
188
|
+
}
|
|
189
|
+
state.complete_job(
|
|
190
|
+
job_id,
|
|
191
|
+
result=payload,
|
|
192
|
+
rows_inserted=result.count,
|
|
193
|
+
error="\n".join(result.errors) if result.errors else None,
|
|
194
|
+
)
|
|
195
|
+
except generation_errors(orch, additional=(UnknownConnectionError, ImportError)) as exc:
|
|
196
|
+
error = f"{type(exc).__name__}: {_error_detail(exc)}"
|
|
197
|
+
state.complete_job(job_id, error=error)
|
|
198
|
+
logger.error("fill job failed", job_id=job_id, error=error)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --------------------------------------------------------------------------
|
|
202
|
+
# Meta: generators / hooks / providers / AI — the acceptance cockpit
|
|
203
|
+
# --------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _generator_param_schema() -> dict[str, list[str]]:
|
|
207
|
+
"""Param names per generator, from ``BaseProvider._gen_*`` signatures."""
|
|
208
|
+
from sqlseed.generators.base_provider import BaseProvider
|
|
209
|
+
|
|
210
|
+
provider = BaseProvider()
|
|
211
|
+
schema: dict[str, list[str]] = {}
|
|
212
|
+
for name in GeneratorDispatchMixin.GENERATOR_MAP:
|
|
213
|
+
if (method := getattr(provider, f"_gen_{name}", None)) is None:
|
|
214
|
+
schema[name] = []
|
|
215
|
+
continue
|
|
216
|
+
params = [p for p in inspect.signature(method).parameters if p != "self"]
|
|
217
|
+
schema[name] = params
|
|
218
|
+
return schema
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@router.get("/meta/generators")
|
|
222
|
+
def meta_generators() -> dict[str, Any]:
|
|
223
|
+
names = sorted(GeneratorDispatchMixin.GENERATOR_MAP.keys())
|
|
224
|
+
return {"count": len(names), "names": names, "params": _generator_param_schema()}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@router.get("/meta/hooks")
|
|
228
|
+
def meta_hooks() -> dict[str, Any]:
|
|
229
|
+
from sqlseed.plugins.hookspecs import SqlseedHookSpec
|
|
230
|
+
|
|
231
|
+
hooks = []
|
|
232
|
+
for name, fn in vars(SqlseedHookSpec).items():
|
|
233
|
+
if name.startswith("sqlseed_") and callable(fn):
|
|
234
|
+
marker: dict[str, Any] | None = getattr(fn, "sqlseed_spec", None)
|
|
235
|
+
hooks.append(
|
|
236
|
+
{
|
|
237
|
+
"name": name,
|
|
238
|
+
"firstresult": marker is not None and bool(marker.get("firstresult")),
|
|
239
|
+
}
|
|
240
|
+
)
|
|
241
|
+
return {"count": len(hooks), "hooks": hooks}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@router.get("/meta/providers")
|
|
245
|
+
def meta_providers() -> dict[str, Any]:
|
|
246
|
+
statuses = provider_availability()
|
|
247
|
+
return {
|
|
248
|
+
"available": [name for name, facts in statuses.items() if facts["available"]],
|
|
249
|
+
"default_chain": ["mimesis", "faker", "base"],
|
|
250
|
+
"statuses": statuses,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# Curated locale list (faker-style codes — the lingua franca across providers:
|
|
255
|
+
# MimesisProvider.set_locale maps these to mimesis short codes internally).
|
|
256
|
+
# Keep in sync with the locale_map in mimesis_provider.py.
|
|
257
|
+
SUPPORTED_LOCALES: list[dict[str, str]] = [
|
|
258
|
+
{"code": "zh_CN", "label": "简体中文(中国)"},
|
|
259
|
+
{"code": "en_US", "label": "English (US)"},
|
|
260
|
+
{"code": "en_GB", "label": "English (UK)"},
|
|
261
|
+
{"code": "zh_TW", "label": "繁體中文(台灣)"},
|
|
262
|
+
{"code": "ja_JP", "label": "日本語"},
|
|
263
|
+
{"code": "ko_KR", "label": "한국어"},
|
|
264
|
+
{"code": "de_DE", "label": "Deutsch"},
|
|
265
|
+
{"code": "fr_FR", "label": "Français"},
|
|
266
|
+
{"code": "es_ES", "label": "Español"},
|
|
267
|
+
{"code": "pt_BR", "label": "Português (Brasil)"},
|
|
268
|
+
{"code": "ru_RU", "label": "Русский"},
|
|
269
|
+
{"code": "it_IT", "label": "Italiano"},
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
# File suffixes recognized as local database files (SQLite family).
|
|
273
|
+
DB_FILE_SUFFIXES = {".db", ".sqlite", ".sqlite3", ".db3"}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@router.get("/meta/locales")
|
|
277
|
+
def meta_locales() -> dict[str, Any]:
|
|
278
|
+
return {"locales": SUPPORTED_LOCALES, "default": "en_US"}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@router.get("/meta/dialects")
|
|
282
|
+
def meta_dialects() -> dict[str, Any]:
|
|
283
|
+
"""Connection kinds the UI offers (core supports SQLite + PostgreSQL today)."""
|
|
284
|
+
return {
|
|
285
|
+
"kinds": [
|
|
286
|
+
{"id": "sqlite", "label": "本地数据库文件", "hint": "SQLite 文件(.db / .sqlite / .sqlite3)"},
|
|
287
|
+
{"id": "postgresql", "label": "PostgreSQL", "hint": "字段化填写连接参数"},
|
|
288
|
+
{"id": "url", "label": "自定义 URL", "hint": "任意 SQLAlchemy URL(为未来数据库预留)"},
|
|
289
|
+
]
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@router.get(
|
|
294
|
+
"/fs/browse",
|
|
295
|
+
responses={
|
|
296
|
+
400: {"description": HTTPStatus(400).phrase},
|
|
297
|
+
403: {"description": HTTPStatus(403).phrase},
|
|
298
|
+
404: {"description": HTTPStatus(404).phrase},
|
|
299
|
+
},
|
|
300
|
+
)
|
|
301
|
+
def fs_browse(path: str | None = None, all_files: bool = False) -> dict[str, Any]:
|
|
302
|
+
"""List a local directory for the file picker modal.
|
|
303
|
+
|
|
304
|
+
The UI server runs on the user's own machine (127.0.0.1), so server-side
|
|
305
|
+
browsing is what makes a real "choose file" button possible — browsers
|
|
306
|
+
never expose absolute paths from ``<input type="file">``.
|
|
307
|
+
"""
|
|
308
|
+
from pathlib import Path
|
|
309
|
+
|
|
310
|
+
home = Path.home()
|
|
311
|
+
target = Path(path).expanduser() if path else home
|
|
312
|
+
if not target.exists():
|
|
313
|
+
raise HTTPException(status_code=404, detail=f"path does not exist: {target}")
|
|
314
|
+
if not target.is_dir():
|
|
315
|
+
raise HTTPException(status_code=400, detail=f"not a directory: {target}")
|
|
316
|
+
entries: list[dict[str, Any]] = []
|
|
317
|
+
try:
|
|
318
|
+
for child in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
|
319
|
+
if child.name.startswith("."):
|
|
320
|
+
continue # hidden files add noise; local tools rarely need them
|
|
321
|
+
is_db = child.suffix.lower() in DB_FILE_SUFFIXES
|
|
322
|
+
if child.is_file() and not all_files and not is_db:
|
|
323
|
+
continue
|
|
324
|
+
entries.append(
|
|
325
|
+
{
|
|
326
|
+
"name": child.name,
|
|
327
|
+
"path": str(child),
|
|
328
|
+
"is_dir": child.is_dir(),
|
|
329
|
+
"is_db": is_db,
|
|
330
|
+
"size": child.stat().st_size if child.is_file() else None,
|
|
331
|
+
}
|
|
332
|
+
)
|
|
333
|
+
except PermissionError as exc:
|
|
334
|
+
raise HTTPException(status_code=403, detail=f"permission denied: {target}") from exc
|
|
335
|
+
return {"path": str(target), "parent": str(target.parent), "home": str(home), "entries": entries}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@router.get("/meta/ai")
|
|
339
|
+
def meta_ai() -> dict[str, Any]:
|
|
340
|
+
"""Effective AI status: env defaults merged with the session-level override.
|
|
341
|
+
|
|
342
|
+
The wizard's readiness check and the meta page both consume this —
|
|
343
|
+
reporting env-only state here made the UI contradict the AI panel
|
|
344
|
+
after an in-session backend switch.
|
|
345
|
+
"""
|
|
346
|
+
try:
|
|
347
|
+
cfg = resolve_settings(state)[0]
|
|
348
|
+
except ImportError:
|
|
349
|
+
return ai_import_failure()
|
|
350
|
+
return {
|
|
351
|
+
"available": True,
|
|
352
|
+
"backend": cfg.backend.value,
|
|
353
|
+
"model": cfg.resolve_model(),
|
|
354
|
+
"api_key_present": bool(cfg.resolve_api_key()),
|
|
355
|
+
"tool_calling_protocol": cfg.resolve_tool_calling_protocol(),
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# --------------------------------------------------------------------------
|
|
360
|
+
# AI config panel (在线/本地大模型 in-UI switching, no env edits / restarts)
|
|
361
|
+
# --------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
# 下拉展示顺序 = 通用程度:OpenAI 兼容协议最通用(vLLM / OpenRouter / 自建网关
|
|
364
|
+
# 都能接,且是 AIConfig 的默认后端),Google AI Studio 次之,本地后端殿后。
|
|
365
|
+
# heal 页会在此之上把「当前生效」的后端再提到第一位(见 heal.js)。
|
|
366
|
+
AI_BACKENDS: list[dict[str, str]] = [
|
|
367
|
+
{"id": "openai_compat", "label": "OpenAI 兼容服务(在线/自建)", "needs_key": "1", "needs_url": "1"},
|
|
368
|
+
{"id": "google_ai_studio", "label": "Google AI Studio(在线)", "needs_key": "1", "needs_url": "0"},
|
|
369
|
+
{"id": "ollama", "label": "Ollama", "needs_key": "0", "needs_url": "0"},
|
|
370
|
+
{"id": "lm_studio", "label": "LM Studio", "needs_key": "0", "needs_url": "0"},
|
|
371
|
+
]
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@router.get("/ai/config")
|
|
375
|
+
def ai_config_get() -> dict[str, Any]:
|
|
376
|
+
"""Current effective AI config: session override merged over env defaults."""
|
|
377
|
+
try:
|
|
378
|
+
cfg = resolve_settings(state)[0]
|
|
379
|
+
except ImportError:
|
|
380
|
+
return ai_import_failure()
|
|
381
|
+
return {
|
|
382
|
+
"available": True,
|
|
383
|
+
"backends": AI_BACKENDS,
|
|
384
|
+
"override": {
|
|
385
|
+
key: value for key, value in state.get_ai_override().items() if key in {"backend", "model", "base_url"}
|
|
386
|
+
},
|
|
387
|
+
"effective": {
|
|
388
|
+
"backend": cfg.backend.value,
|
|
389
|
+
"model": cfg.resolve_model(),
|
|
390
|
+
"base_url": cfg.base_url,
|
|
391
|
+
"api_key_present": bool(cfg.resolve_api_key()),
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@router.post("/ai/config", responses={422: {"description": HTTPStatus(422).phrase}})
|
|
397
|
+
def ai_config_set(req: AIConfigRequest) -> dict[str, Any]:
|
|
398
|
+
"""Store session-level AI overrides (backend/model/key/base_url)."""
|
|
399
|
+
# An empty legacy request still resets only the in-memory overrides.
|
|
400
|
+
if not req.model_fields_set:
|
|
401
|
+
state.set_ai_override({})
|
|
402
|
+
else:
|
|
403
|
+
try:
|
|
404
|
+
set_session_preferences(state, req.model_dump(exclude_unset=True))
|
|
405
|
+
except ImportError:
|
|
406
|
+
return ai_config_get()
|
|
407
|
+
except (ValueError, TypeError) as exc:
|
|
408
|
+
raise HTTPException(422, detail="AI 配置字段无效,请检查后端与 HTTP(S) 地址。") from exc
|
|
409
|
+
return ai_config_get()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _local_ai_probe_result(result: dict[str, Any], response: httpx.Response, base: str) -> None:
|
|
413
|
+
"""Describe local reachability, preserving a successful probe with malformed model metadata."""
|
|
414
|
+
result["ok"] = response.status_code == 200
|
|
415
|
+
if result["ok"]:
|
|
416
|
+
try:
|
|
417
|
+
result["models"] = [str(model.get("id")) for model in response.json().get("data", []) if model.get("id")]
|
|
418
|
+
except (ValueError, AttributeError):
|
|
419
|
+
pass
|
|
420
|
+
model_hint = f"可用模型:{', '.join(result['models'])}" if result["models"] else "未列出模型"
|
|
421
|
+
result["message"] = f"本地服务可达({base})。无需 API Key。{model_hint}"
|
|
422
|
+
else:
|
|
423
|
+
result["message"] = f"本地服务响应异常:HTTP {response.status_code}"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@router.post("/ai/test-connection")
|
|
427
|
+
def ai_test_connection() -> dict[str, Any]:
|
|
428
|
+
"""Ping the effective AI backend and return a friendly status.
|
|
429
|
+
|
|
430
|
+
Ollama/LM Studio need NO API key — a reachable local server is enough.
|
|
431
|
+
This endpoint makes that explicit in the UI instead of a bare 503.
|
|
432
|
+
The probe URL is ``{base_url}/models`` (OpenAI-compatible list endpoint;
|
|
433
|
+
all four backends serve it — probing the bare Ollama host's ``/models``
|
|
434
|
+
returns 404 and once made a healthy server look dead).
|
|
435
|
+
"""
|
|
436
|
+
try:
|
|
437
|
+
cfg = resolve_settings(state)[0]
|
|
438
|
+
import httpx
|
|
439
|
+
except ImportError:
|
|
440
|
+
return ai_import_failure()
|
|
441
|
+
backend = cfg.backend.value
|
|
442
|
+
result: dict[str, Any] = {"available": True, "backend": backend, "models": []}
|
|
443
|
+
try:
|
|
444
|
+
base = cfg.resolve_base_url()
|
|
445
|
+
probe_url = base.rstrip("/") + "/models"
|
|
446
|
+
if backend in {"ollama", "lm_studio"}:
|
|
447
|
+
# Local servers: reachability is the whole story; no key needed.
|
|
448
|
+
resp = httpx.get(probe_url, timeout=5)
|
|
449
|
+
_local_ai_probe_result(result, resp, base)
|
|
450
|
+
elif not (key := cfg.resolve_api_key()):
|
|
451
|
+
result["ok"] = False
|
|
452
|
+
result["message"] = "在线后端需要 API Key:请在 AI 配置面板填写,或设置 GOOGLE_API_KEY / OPENAI_API_KEY。"
|
|
453
|
+
else:
|
|
454
|
+
resp = httpx.get(probe_url, headers={"Authorization": f"Bearer {key}"}, timeout=8)
|
|
455
|
+
result["ok"] = resp.status_code == 200
|
|
456
|
+
result["message"] = (
|
|
457
|
+
"在线后端连通且 Key 有效。"
|
|
458
|
+
if result["ok"]
|
|
459
|
+
else f"在线后端拒绝:HTTP {resp.status_code}(检查 Key / Base URL)。"
|
|
460
|
+
)
|
|
461
|
+
except (httpx.HTTPError, OSError, ValueError, RuntimeError):
|
|
462
|
+
result["ok"] = False
|
|
463
|
+
result["message"] = "无法连接 AI 服务,请检查地址、认证和服务状态。"
|
|
464
|
+
if backend == "ollama":
|
|
465
|
+
result["message"] += "(本地需先运行 `ollama serve`,默认 http://localhost:11434;无需任何密钥)"
|
|
466
|
+
return result
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@router.get("/meta/info")
|
|
470
|
+
def meta_info() -> dict[str, Any]:
|
|
471
|
+
import sqlseed
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
"sqlseed_version": getattr(sqlseed, "__version__", "unknown"),
|
|
475
|
+
"generators": len(GeneratorDispatchMixin.GENERATOR_MAP),
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# --------------------------------------------------------------------------
|
|
480
|
+
# Connections
|
|
481
|
+
# --------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@router.post(
|
|
485
|
+
"/connections",
|
|
486
|
+
responses={400: {"description": HTTPStatus(400).phrase}, 422: {"description": HTTPStatus(422).phrase}},
|
|
487
|
+
)
|
|
488
|
+
def connect_db(req: ConnectRequest) -> dict[str, Any]:
|
|
489
|
+
if bool(req.db_path) == bool(req.url):
|
|
490
|
+
raise HTTPException(status_code=422, detail="provide exactly one of db_path / url")
|
|
491
|
+
if (target := req.db_path or req.url) is None: # unreachable; narrows the type for mypy strict
|
|
492
|
+
raise HTTPException(status_code=422, detail="empty connection target")
|
|
493
|
+
conn: Any = None
|
|
494
|
+
try:
|
|
495
|
+
conn = state.add_connection(target, provider=req.provider, locale=req.locale)
|
|
496
|
+
orch = conn.orchestrator
|
|
497
|
+
tables = orch.get_table_names()
|
|
498
|
+
except Exception as exc:
|
|
499
|
+
if conn is not None:
|
|
500
|
+
state.close_connection(conn.conn_id)
|
|
501
|
+
raise HTTPException(status_code=400, detail=f"connection failed: {exc}") from exc
|
|
502
|
+
return {
|
|
503
|
+
"conn_id": conn.conn_id,
|
|
504
|
+
"target": target,
|
|
505
|
+
"provider": conn.provider,
|
|
506
|
+
"locale": conn.locale,
|
|
507
|
+
"tables": [
|
|
508
|
+
{
|
|
509
|
+
"name": t,
|
|
510
|
+
"row_count": orch.get_row_count(t),
|
|
511
|
+
"column_count": len(orch.get_column_names(t)),
|
|
512
|
+
"foreign_keys": len(orch.get_foreign_keys(t)),
|
|
513
|
+
}
|
|
514
|
+
for t in tables
|
|
515
|
+
],
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@router.get(
|
|
520
|
+
"/connections/{conn_id}/tables",
|
|
521
|
+
responses={400: {"description": HTTPStatus(400).phrase}, 404: {"description": HTTPStatus(404).phrase}},
|
|
522
|
+
)
|
|
523
|
+
def list_tables(conn_id: str) -> dict[str, Any]:
|
|
524
|
+
"""Table summary for an existing connection.
|
|
525
|
+
|
|
526
|
+
Exists so the frontend can restore its state after a page reload: the
|
|
527
|
+
connection object survives server-side, but the browser's module-level
|
|
528
|
+
``store`` (connId/target/tables) is wiped, and the wizard needs the table
|
|
529
|
+
list to rebuild its tree. Shape mirrors the POST /connections response.
|
|
530
|
+
"""
|
|
531
|
+
orch = _conn_or_404(conn_id)
|
|
532
|
+
try:
|
|
533
|
+
return {
|
|
534
|
+
"conn_id": conn_id,
|
|
535
|
+
"target": state.get_connection(conn_id).target,
|
|
536
|
+
"tables": [
|
|
537
|
+
{
|
|
538
|
+
"name": t,
|
|
539
|
+
"row_count": orch.get_row_count(t),
|
|
540
|
+
"column_count": len(orch.get_column_names(t)),
|
|
541
|
+
"foreign_keys": len(orch.get_foreign_keys(t)),
|
|
542
|
+
}
|
|
543
|
+
for t in orch.get_table_names()
|
|
544
|
+
],
|
|
545
|
+
}
|
|
546
|
+
except HTTPException:
|
|
547
|
+
raise
|
|
548
|
+
except generation_errors(orch) as exc:
|
|
549
|
+
raise HTTPException(status_code=400, detail=_error_detail(exc)) from exc
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
@router.get("/connections")
|
|
553
|
+
def list_connections() -> dict[str, Any]:
|
|
554
|
+
return {"connections": state.list_connections()}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
@router.delete(
|
|
558
|
+
"/connections/{conn_id}",
|
|
559
|
+
responses={404: {"description": HTTPStatus(404).phrase}, 409: {"description": HTTPStatus(409).phrase}},
|
|
560
|
+
)
|
|
561
|
+
def close_db(conn_id: str) -> dict[str, Any]:
|
|
562
|
+
try:
|
|
563
|
+
state.close_connection(conn_id)
|
|
564
|
+
except ConnectionBusyError as exc:
|
|
565
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
566
|
+
except KeyError as exc:
|
|
567
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
568
|
+
return {"closed": conn_id}
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@router.get("/jobs")
|
|
572
|
+
def jobs() -> dict[str, Any]:
|
|
573
|
+
return {
|
|
574
|
+
"jobs": [
|
|
575
|
+
{
|
|
576
|
+
"job_id": j.job_id,
|
|
577
|
+
"conn_id": j.conn_id,
|
|
578
|
+
"kind": j.kind,
|
|
579
|
+
"label": j.label,
|
|
580
|
+
"status": j.status,
|
|
581
|
+
"rows_inserted": j.rows_inserted,
|
|
582
|
+
"error": j.error,
|
|
583
|
+
"result": j.result,
|
|
584
|
+
}
|
|
585
|
+
for j in state.recent_jobs()
|
|
586
|
+
]
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@router.get("/jobs/{job_id}", responses={404: {"description": HTTPStatus(404).phrase}})
|
|
591
|
+
def job_status(job_id: str) -> dict[str, Any]:
|
|
592
|
+
try:
|
|
593
|
+
job = state.job_snapshot(job_id)
|
|
594
|
+
except KeyError as exc:
|
|
595
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
596
|
+
live_rows = None
|
|
597
|
+
if job.status == "running" and job.kind == "fill":
|
|
598
|
+
orch = None
|
|
599
|
+
try:
|
|
600
|
+
orch = state.get_connection(job.conn_id).orchestrator
|
|
601
|
+
live_rows = orch.get_row_count(job.label)
|
|
602
|
+
except generation_errors(orch, additional=(UnknownConnectionError,)):
|
|
603
|
+
live_rows = None
|
|
604
|
+
return {
|
|
605
|
+
"job_id": job.job_id,
|
|
606
|
+
"status": job.status,
|
|
607
|
+
"rows_inserted": job.rows_inserted,
|
|
608
|
+
"rows_before": job.rows_before,
|
|
609
|
+
"live_rows": live_rows,
|
|
610
|
+
"error": job.error,
|
|
611
|
+
"result": job.result,
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
# --------------------------------------------------------------------------
|
|
616
|
+
# Schema + mapping (the 9-level chain, observable)
|
|
617
|
+
# --------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
@router.get(
|
|
621
|
+
"/connections/{conn_id}/tables/{table}/schema",
|
|
622
|
+
responses={400: {"description": HTTPStatus(400).phrase}, 404: {"description": HTTPStatus(404).phrase}},
|
|
623
|
+
)
|
|
624
|
+
def table_schema(conn_id: str, table: str) -> dict[str, Any]:
|
|
625
|
+
orch = _conn_or_404(conn_id)
|
|
626
|
+
try:
|
|
627
|
+
validate_table_name(table)
|
|
628
|
+
if not (columns := _serialize(orch.get_column_info(table))):
|
|
629
|
+
raise ValueError(f"Table '{table}' does not exist")
|
|
630
|
+
fks = _serialize(orch.get_foreign_keys(table))
|
|
631
|
+
skippable = sorted(orch.get_skippable_columns(table))
|
|
632
|
+
# 数据库硬唯一约束列(主键/唯一索引/UNIQUE 约束)——前端属性面板
|
|
633
|
+
# 用它把「设置唯一」锁定为必开,避免用户配出必 IntegrityError 的组合。
|
|
634
|
+
unique_columns = sorted(orch._schema.detect_unique_columns(table))
|
|
635
|
+
row_count = orch.get_row_count(table)
|
|
636
|
+
except generation_errors(orch) as exc:
|
|
637
|
+
raise HTTPException(status_code=400, detail=_error_detail(exc)) from exc
|
|
638
|
+
return {
|
|
639
|
+
"table": table,
|
|
640
|
+
"row_count": row_count,
|
|
641
|
+
"columns": columns,
|
|
642
|
+
"foreign_keys": fks,
|
|
643
|
+
"skippable": skippable,
|
|
644
|
+
"unique_columns": unique_columns,
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
@router.get(
|
|
649
|
+
"/connections/{conn_id}/topo-order",
|
|
650
|
+
responses={400: {"description": HTTPStatus(400).phrase}, 404: {"description": HTTPStatus(404).phrase}},
|
|
651
|
+
)
|
|
652
|
+
def topo_order(conn_id: str, tables: str | None = None) -> dict[str, Any]:
|
|
653
|
+
"""FK-topological table order (referenced tables first) — the wizard's
|
|
654
|
+
"表生成顺序" (参考工具 parity). Defaults to all tables of the connection."""
|
|
655
|
+
orch = _conn_or_404(conn_id)
|
|
656
|
+
names = [t for t in (tables or "").split(",") if t] or orch.get_table_names()
|
|
657
|
+
try:
|
|
658
|
+
order = orch.get_topological_table_order(names)
|
|
659
|
+
except generation_errors(orch) as exc:
|
|
660
|
+
raise HTTPException(status_code=400, detail=_error_detail(exc)) from exc
|
|
661
|
+
return {"tables": order}
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
@router.get(
|
|
665
|
+
"/connections/{conn_id}/tables/{table}/mapping",
|
|
666
|
+
responses={400: {"description": HTTPStatus(400).phrase}, 404: {"description": HTTPStatus(404).phrase}},
|
|
667
|
+
)
|
|
668
|
+
def table_mapping(conn_id: str, table: str) -> dict[str, Any]:
|
|
669
|
+
orch = _conn_or_404(conn_id)
|
|
670
|
+
try:
|
|
671
|
+
validate_table_name(table)
|
|
672
|
+
specs = orch.get_column_mapping(table)
|
|
673
|
+
except generation_errors(orch) as exc:
|
|
674
|
+
raise HTTPException(status_code=400, detail=_error_detail(exc)) from exc
|
|
675
|
+
return {"table": table, "mapping": {col: _serialize(spec) for col, spec in specs.items()}}
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
@router.get(
|
|
679
|
+
"/connections/{conn_id}/tables/{table}/yaml-template",
|
|
680
|
+
responses={400: {"description": HTTPStatus(400).phrase}, 404: {"description": HTTPStatus(404).phrase}},
|
|
681
|
+
)
|
|
682
|
+
def table_yaml_template(conn_id: str, table: str) -> dict[str, Any]:
|
|
683
|
+
"""Generate a fillable YAML skeleton from the inferred mapping."""
|
|
684
|
+
orch = _conn_or_404(conn_id)
|
|
685
|
+
try:
|
|
686
|
+
validate_table_name(table)
|
|
687
|
+
specs = orch.get_column_mapping(table)
|
|
688
|
+
except generation_errors(orch) as exc:
|
|
689
|
+
raise HTTPException(status_code=400, detail=_error_detail(exc)) from exc
|
|
690
|
+
target = state.get_connection(conn_id).target
|
|
691
|
+
columns: dict[str, Any] = {}
|
|
692
|
+
for col, spec in specs.items():
|
|
693
|
+
if (gen := spec.generator_name) in {"skip", "__enrich__"}:
|
|
694
|
+
continue
|
|
695
|
+
entry: dict[str, Any] = {"generator": gen}
|
|
696
|
+
if spec.params:
|
|
697
|
+
entry["params"] = dict(spec.params)
|
|
698
|
+
if spec.null_ratio:
|
|
699
|
+
entry["null_ratio"] = spec.null_ratio
|
|
700
|
+
columns[col] = entry
|
|
701
|
+
config = {
|
|
702
|
+
"url" if "://" in target else "db_path": target,
|
|
703
|
+
"provider": state.get_connection(conn_id).provider,
|
|
704
|
+
"tables": [{"name": table, "count": 100, "columns": [{"name": c, **v} for c, v in columns.items()]}],
|
|
705
|
+
}
|
|
706
|
+
return {"yaml": yaml.safe_dump(config, sort_keys=False, allow_unicode=True)}
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
# --------------------------------------------------------------------------
|
|
710
|
+
# Preview / fill / data
|
|
711
|
+
# --------------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
@router.post(
|
|
715
|
+
"/connections/{conn_id}/preview",
|
|
716
|
+
responses={
|
|
717
|
+
400: {"description": HTTPStatus(400).phrase},
|
|
718
|
+
404: {"description": HTTPStatus(404).phrase},
|
|
719
|
+
409: {"description": HTTPStatus(409).phrase},
|
|
720
|
+
},
|
|
721
|
+
)
|
|
722
|
+
def preview_rows(conn_id: str, req: PreviewRequest) -> dict[str, Any]:
|
|
723
|
+
orch = _conn_or_404(conn_id)
|
|
724
|
+
try:
|
|
725
|
+
with state.connection_operation(conn_id) as conn:
|
|
726
|
+
rows = conn.orchestrator.preview_table(
|
|
727
|
+
req.table,
|
|
728
|
+
count=req.count,
|
|
729
|
+
columns=req.columns,
|
|
730
|
+
seed=req.seed,
|
|
731
|
+
transform=req.transform,
|
|
732
|
+
enrich=req.enrich,
|
|
733
|
+
)
|
|
734
|
+
except UnknownConnectionError as exc:
|
|
735
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
736
|
+
except ConnectionBusyError as exc:
|
|
737
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
738
|
+
except generation_errors(orch) as exc:
|
|
739
|
+
raise HTTPException(status_code=400, detail=f"preview failed: {_error_detail(exc)}") from exc
|
|
740
|
+
return {"table": req.table, "rows": _serialize(rows)}
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
@router.post(
|
|
744
|
+
"/connections/{conn_id}/fill",
|
|
745
|
+
responses={
|
|
746
|
+
404: {"description": HTTPStatus(404).phrase},
|
|
747
|
+
409: {"description": HTTPStatus(409).phrase},
|
|
748
|
+
503: {"description": HTTPStatus(503).phrase},
|
|
749
|
+
},
|
|
750
|
+
)
|
|
751
|
+
def start_fill(conn_id: str, req: FillRequest) -> dict[str, Any]:
|
|
752
|
+
_conn_or_404(conn_id)
|
|
753
|
+
try:
|
|
754
|
+
with state.connection_operation(conn_id, write=True):
|
|
755
|
+
job = state.create_job(conn_id, kind="fill", label=req.table)
|
|
756
|
+
except KeyError as exc:
|
|
757
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
758
|
+
except ConnectionBusyError as exc:
|
|
759
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
760
|
+
try:
|
|
761
|
+
start_background(target=_run_fill_job, args=(conn_id, job.job_id, req), category="job")
|
|
762
|
+
except Exception as exc:
|
|
763
|
+
state.complete_job(job.job_id, error=_error_detail(exc))
|
|
764
|
+
raise HTTPException(status_code=503, detail="无法启动生成任务,请重试。") from exc
|
|
765
|
+
return {"job_id": job.job_id, "table": req.table, "count": req.count}
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
@router.get(
|
|
769
|
+
"/connections/{conn_id}/tables/{table}/rows",
|
|
770
|
+
responses={400: {"description": HTTPStatus(400).phrase}, 404: {"description": HTTPStatus(404).phrase}},
|
|
771
|
+
)
|
|
772
|
+
def table_rows(conn_id: str, table: str, limit: int = 50, offset: int = 0) -> dict[str, Any]:
|
|
773
|
+
orch = _conn_or_404(conn_id)
|
|
774
|
+
try:
|
|
775
|
+
validate_table_name(table)
|
|
776
|
+
total = orch.get_row_count(table)
|
|
777
|
+
sql = f"SELECT * FROM {quote_identifier(table)} LIMIT ? OFFSET ?"
|
|
778
|
+
rows = orch.query(sql, (limit, offset))
|
|
779
|
+
except generation_errors(orch) as exc:
|
|
780
|
+
raise HTTPException(status_code=400, detail=_error_detail(exc)) from exc
|
|
781
|
+
return {"table": table, "total": total, "limit": limit, "offset": offset, "rows": _serialize(rows)}
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
class QueryRequest(BaseModel):
|
|
785
|
+
sql: str
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
@router.post(
|
|
789
|
+
"/connections/{conn_id}/query",
|
|
790
|
+
responses={
|
|
791
|
+
400: {"description": HTTPStatus(400).phrase},
|
|
792
|
+
404: {"description": HTTPStatus(404).phrase},
|
|
793
|
+
422: {"description": HTTPStatus(422).phrase},
|
|
794
|
+
},
|
|
795
|
+
)
|
|
796
|
+
def run_query(conn_id: str, req: QueryRequest) -> dict[str, Any]:
|
|
797
|
+
"""Read-only SQL console: SELECT statements only."""
|
|
798
|
+
statement = (req.sql or "").strip().rstrip(";")
|
|
799
|
+
if not statement.lower().startswith("select") or ";" in statement:
|
|
800
|
+
raise HTTPException(status_code=422, detail="only single read-only SELECT statements are allowed")
|
|
801
|
+
orch = _conn_or_404(conn_id)
|
|
802
|
+
try:
|
|
803
|
+
rows = orch.query(statement)
|
|
804
|
+
except generation_errors(orch) as exc:
|
|
805
|
+
raise HTTPException(status_code=400, detail=_error_detail(exc)) from exc
|
|
806
|
+
return {"rows": _serialize(rows)}
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
# --------------------------------------------------------------------------
|
|
810
|
+
# YAML round-trip (uses core load_config for validation parity with CLI)
|
|
811
|
+
# --------------------------------------------------------------------------
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
@router.post("/config/parse")
|
|
815
|
+
def config_parse(req: YamlRequest) -> dict[str, Any]:
|
|
816
|
+
try:
|
|
817
|
+
cfg = load_config_from_text(req.yaml)
|
|
818
|
+
except HTTPException:
|
|
819
|
+
raise
|
|
820
|
+
except (ValueError, TypeError, OSError, yaml.YAMLError) as exc:
|
|
821
|
+
return {"valid": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
822
|
+
return {"valid": True, "config": _serialize(config_to_dict(cfg))}
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
@router.post("/config/serialize", responses={422: {"description": HTTPStatus(422).phrase}})
|
|
826
|
+
def config_serialize(req: YamlRequest) -> dict[str, Any]:
|
|
827
|
+
data = _yaml_to_config_dict(req.yaml)
|
|
828
|
+
return {"yaml": yaml.safe_dump(data, sort_keys=False, allow_unicode=True)}
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def load_config_from_text(yaml_text: str) -> GeneratorConfig:
|
|
832
|
+
"""Load a GeneratorConfig from YAML text via a temp file (core API is path-based)."""
|
|
833
|
+
import tempfile
|
|
834
|
+
from pathlib import Path
|
|
835
|
+
|
|
836
|
+
with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False, encoding="utf-8") as f:
|
|
837
|
+
f.write(yaml_text)
|
|
838
|
+
path = Path(f.name)
|
|
839
|
+
try:
|
|
840
|
+
return load_config(str(path))
|
|
841
|
+
finally:
|
|
842
|
+
path.unlink(missing_ok=True)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def config_to_dict(cfg: GeneratorConfig) -> dict[str, Any]:
|
|
846
|
+
"""Preserve every validated field in a JSON-safe YAML-shaped mapping."""
|
|
847
|
+
return cfg.model_dump(mode="json", exclude_none=True)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
# --------------------------------------------------------------------------
|
|
851
|
+
# Self-heal laboratory (Layers 2 / 3 / 5)
|
|
852
|
+
# --------------------------------------------------------------------------
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _require_ai_export(module_name: str, export_name: str) -> None:
|
|
856
|
+
module = importlib.import_module(module_name)
|
|
857
|
+
try:
|
|
858
|
+
getattr(module, export_name)
|
|
859
|
+
except AttributeError as exc:
|
|
860
|
+
raise ImportError(f"Required AI export is unavailable: {module_name}.{export_name}") from exc
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _require_sqlseed_ai() -> None:
|
|
864
|
+
try:
|
|
865
|
+
require_ai_available()
|
|
866
|
+
_require_ai_export("sqlseed_ai.contracts.builtin_violations", "BUILTIN_VIOLATIONS")
|
|
867
|
+
except ImportError as exc:
|
|
868
|
+
raise HTTPException(
|
|
869
|
+
status_code=503,
|
|
870
|
+
detail=ai_import_failure(),
|
|
871
|
+
) from exc
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _build_snapshot(conn_id: str) -> Any:
|
|
875
|
+
from sqlseed_ai.validator.schema_snapshot import SchemaSnapshot
|
|
876
|
+
|
|
877
|
+
conn = state.get_connection(conn_id)
|
|
878
|
+
if "://" in conn.target:
|
|
879
|
+
return SchemaSnapshot(url=conn.target)
|
|
880
|
+
return SchemaSnapshot(db_path=conn.target)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
@router.post(
|
|
884
|
+
"/connections/{conn_id}/heal/validate",
|
|
885
|
+
responses={
|
|
886
|
+
404: {"description": HTTPStatus(404).phrase},
|
|
887
|
+
422: {"description": HTTPStatus(422).phrase},
|
|
888
|
+
503: {"description": HTTPStatus(503).phrase},
|
|
889
|
+
},
|
|
890
|
+
)
|
|
891
|
+
def heal_validate(conn_id: str, req: HealValidateRequest) -> dict[str, Any]:
|
|
892
|
+
_require_sqlseed_ai()
|
|
893
|
+
_conn_or_404(conn_id)
|
|
894
|
+
from sqlseed_ai.contracts.builtin_violations import BUILTIN_VIOLATIONS
|
|
895
|
+
from sqlseed_ai.contracts.matrix import ContractResolver
|
|
896
|
+
from sqlseed_ai.validator.main import FastValidator
|
|
897
|
+
|
|
898
|
+
config = _yaml_to_config_dict(req.yaml)
|
|
899
|
+
conn = state.get_connection(conn_id)
|
|
900
|
+
try:
|
|
901
|
+
snapshot = _build_snapshot(conn_id)
|
|
902
|
+
is_url = "://" in conn.target
|
|
903
|
+
resolver = ContractResolver(set(BUILTIN_VIOLATIONS), set())
|
|
904
|
+
validator = FastValidator(
|
|
905
|
+
resolver,
|
|
906
|
+
db_path=None if is_url else conn.target,
|
|
907
|
+
url=conn.target if is_url else None,
|
|
908
|
+
)
|
|
909
|
+
result = validator.validate(config, snapshot, dialect=req.dialect)
|
|
910
|
+
except HTTPException:
|
|
911
|
+
raise
|
|
912
|
+
except generation_errors(conn.orchestrator) as exc:
|
|
913
|
+
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
914
|
+
return {
|
|
915
|
+
"ok": True,
|
|
916
|
+
"is_clean": result.is_clean,
|
|
917
|
+
"violation_count": len(result.violations),
|
|
918
|
+
"violations": [_serialize(v) for v in result.violations],
|
|
919
|
+
"column_groups": [_serialize(g) for g in result.column_groups],
|
|
920
|
+
"schema_hash": snapshot.schema_hash,
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
@router.post(
|
|
925
|
+
"/connections/{conn_id}/heal/repair",
|
|
926
|
+
responses={
|
|
927
|
+
404: {"description": HTTPStatus(404).phrase},
|
|
928
|
+
422: {"description": HTTPStatus(422).phrase},
|
|
929
|
+
503: {"description": HTTPStatus(503).phrase},
|
|
930
|
+
},
|
|
931
|
+
)
|
|
932
|
+
def heal_repair(conn_id: str, req: YamlRequest) -> dict[str, Any]:
|
|
933
|
+
_require_sqlseed_ai()
|
|
934
|
+
_conn_or_404(conn_id)
|
|
935
|
+
from sqlseed_ai.contracts.builtin_violations import BUILTIN_VIOLATIONS
|
|
936
|
+
from sqlseed_ai.contracts.matrix import ContractResolver
|
|
937
|
+
from sqlseed_ai.repair.pipeline import RepairPipeline
|
|
938
|
+
|
|
939
|
+
config = _yaml_to_config_dict(req.yaml)
|
|
940
|
+
conn = state.get_connection(conn_id)
|
|
941
|
+
try:
|
|
942
|
+
snapshot = _build_snapshot(conn_id)
|
|
943
|
+
resolver = ContractResolver(set(BUILTIN_VIOLATIONS), set())
|
|
944
|
+
is_url = "://" in conn.target
|
|
945
|
+
pipeline = RepairPipeline(
|
|
946
|
+
resolver,
|
|
947
|
+
db_path=None if is_url else conn.target,
|
|
948
|
+
url=conn.target if is_url else None,
|
|
949
|
+
)
|
|
950
|
+
config, repair_result = pipeline.run(config, snapshot)
|
|
951
|
+
except HTTPException:
|
|
952
|
+
raise
|
|
953
|
+
except generation_errors(conn.orchestrator) as exc:
|
|
954
|
+
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
955
|
+
return {
|
|
956
|
+
"ok": True,
|
|
957
|
+
"fix_count": repair_result.fix_count,
|
|
958
|
+
"applied_fixes": [_serialize(f) for f in repair_result.applied_fixes],
|
|
959
|
+
"unfixable": [_serialize(v) for v in repair_result.unfixable],
|
|
960
|
+
"repaired_yaml": yaml.safe_dump(config, sort_keys=False, allow_unicode=True),
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
class _CountingLLMClient:
|
|
965
|
+
"""Proxy over the LLM client that counts ``chat_completions_create`` calls.
|
|
966
|
+
|
|
967
|
+
The auto-heal pipeline is deterministic-first (clean subgraphs skip
|
|
968
|
+
the LLM layer entirely), so a run can finish without calling the LLM.
|
|
969
|
+
The count is reported as ``job.result.llm_calls`` to make that visible.
|
|
970
|
+
"""
|
|
971
|
+
|
|
972
|
+
def __init__(self, inner: Any) -> None:
|
|
973
|
+
self._inner = inner
|
|
974
|
+
self.calls = 0
|
|
975
|
+
|
|
976
|
+
def chat_completions_create(self, **kwargs: Any) -> Any:
|
|
977
|
+
self.calls += 1
|
|
978
|
+
return self._inner.chat_completions_create(**kwargs)
|
|
979
|
+
|
|
980
|
+
def __getattr__(self, name: str) -> Any:
|
|
981
|
+
# Passthrough so the proxy satisfies the full LLMClient surface.
|
|
982
|
+
return getattr(self._inner, name)
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def _run_auto_heal_job(conn_id: str, job_id: str, req: AutoHealRequest) -> None:
|
|
986
|
+
"""Background-thread body for the full auto-heal pipeline (Layer 5)."""
|
|
987
|
+
with state.job_completion(job_id):
|
|
988
|
+
database_orch: DataOrchestrator | None = None
|
|
989
|
+
ai_errors: tuple[type[Exception], ...] = (ImportError,)
|
|
990
|
+
try:
|
|
991
|
+
conn = state.get_connection(conn_id)
|
|
992
|
+
database_orch = conn.orchestrator
|
|
993
|
+
from sqlseed_ai._client import APIError
|
|
994
|
+
from sqlseed_ai.auto_heal.orchestrator import AutoHealOrchestrator
|
|
995
|
+
from sqlseed_ai.contracts.builtin_violations import BUILTIN_VIOLATIONS
|
|
996
|
+
from sqlseed_ai.contracts.matrix import ContractResolver
|
|
997
|
+
from sqlseed_ai.runtime import build_ai_config, build_heal_orchestrator, build_llm_client
|
|
998
|
+
from sqlseed_ai.validator.main import FastValidator
|
|
999
|
+
from sqlseed_ai.validator.schema_snapshot import SchemaSnapshot
|
|
1000
|
+
|
|
1001
|
+
ai_errors += (APIError,)
|
|
1002
|
+
# Request overrides may change service; bind authentication before the
|
|
1003
|
+
# shared runtime builder can fall back to an environment credential.
|
|
1004
|
+
current, _ = resolve_settings(state)
|
|
1005
|
+
request_settings = SettingsRequest.model_validate(
|
|
1006
|
+
{
|
|
1007
|
+
"backend": req.backend or current.backend.value,
|
|
1008
|
+
"model": req.model or current.model or "",
|
|
1009
|
+
"base_url": req.base_url or current.base_url or "",
|
|
1010
|
+
"api_key": req.api_key or "",
|
|
1011
|
+
}
|
|
1012
|
+
)
|
|
1013
|
+
scoped, _ = resolve_settings(state, request_settings)
|
|
1014
|
+
ai_config = build_ai_config(
|
|
1015
|
+
api_key=scoped.api_key,
|
|
1016
|
+
base_url=scoped.base_url,
|
|
1017
|
+
model=scoped.model,
|
|
1018
|
+
timeout=req.timeout,
|
|
1019
|
+
log_llm=False,
|
|
1020
|
+
)
|
|
1021
|
+
ai_config.backend = scoped.backend
|
|
1022
|
+
ai_config.api_key = scoped.api_key
|
|
1023
|
+
ai_config.base_url = scoped.base_url
|
|
1024
|
+
ai_config.model = scoped.model
|
|
1025
|
+
ai_config = credential_snapshot(ai_config)
|
|
1026
|
+
if not ai_config.resolve_api_key():
|
|
1027
|
+
raise RuntimeError(
|
|
1028
|
+
"AI API key not configured: set it in the AI config panel, or via "
|
|
1029
|
+
"SQLSEED_AI_API_KEY / GOOGLE_API_KEY / OPENAI_API_KEY, or switch to a "
|
|
1030
|
+
"local backend (Ollama / LM Studio) in the panel"
|
|
1031
|
+
)
|
|
1032
|
+
ai_config.model = ai_config.resolve_model()
|
|
1033
|
+
is_url = "://" in conn.target
|
|
1034
|
+
db_path = None if is_url else conn.target
|
|
1035
|
+
url = conn.target if is_url else None
|
|
1036
|
+
resolver = ContractResolver(set(BUILTIN_VIOLATIONS), set())
|
|
1037
|
+
validator = FastValidator(resolver, db_path=db_path, url=url)
|
|
1038
|
+
# Count LLM invocations: the pipeline is deterministic-first — clean
|
|
1039
|
+
# subgraphs skip Layer 4 entirely, so a run may finish WITHOUT any
|
|
1040
|
+
# LLM call. Surfacing the count answers "did the AI actually run?".
|
|
1041
|
+
with closing(build_llm_client(ai_config)) as owned_client:
|
|
1042
|
+
client = _CountingLLMClient(owned_client)
|
|
1043
|
+
prelim_snapshot = SchemaSnapshot(db_path=db_path, url=url)
|
|
1044
|
+
heal_orch = build_heal_orchestrator(
|
|
1045
|
+
ai_config,
|
|
1046
|
+
client,
|
|
1047
|
+
prelim_snapshot,
|
|
1048
|
+
validator,
|
|
1049
|
+
schema_hash=prelim_snapshot.schema_hash,
|
|
1050
|
+
max_retries=3,
|
|
1051
|
+
)
|
|
1052
|
+
orch = AutoHealOrchestrator(
|
|
1053
|
+
db_path=db_path,
|
|
1054
|
+
url=url,
|
|
1055
|
+
heal_orchestrator=heal_orch,
|
|
1056
|
+
validator=validator,
|
|
1057
|
+
total_budget_seconds=req.budget_seconds,
|
|
1058
|
+
verbose=False,
|
|
1059
|
+
)
|
|
1060
|
+
yaml_str = orch.run()
|
|
1061
|
+
state.complete_job(
|
|
1062
|
+
job_id,
|
|
1063
|
+
result={
|
|
1064
|
+
"yaml": yaml_str,
|
|
1065
|
+
"model": ai_config.model,
|
|
1066
|
+
"backend": ai_config.backend.value,
|
|
1067
|
+
"llm_calls": client.calls,
|
|
1068
|
+
},
|
|
1069
|
+
)
|
|
1070
|
+
except generation_errors(database_orch, additional=(UnknownConnectionError,) + ai_errors) as exc:
|
|
1071
|
+
error = f"{type(exc).__name__}: {_error_detail(exc)}"
|
|
1072
|
+
state.complete_job(job_id, error=error)
|
|
1073
|
+
logger.error("auto-heal job failed", job_id=job_id, error=error)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
@router.post(
|
|
1077
|
+
"/connections/{conn_id}/heal/auto",
|
|
1078
|
+
responses={
|
|
1079
|
+
404: {"description": HTTPStatus(404).phrase},
|
|
1080
|
+
409: {"description": HTTPStatus(409).phrase},
|
|
1081
|
+
503: {"description": HTTPStatus(503).phrase},
|
|
1082
|
+
},
|
|
1083
|
+
)
|
|
1084
|
+
def heal_auto(conn_id: str, req: AutoHealRequest) -> dict[str, Any]:
|
|
1085
|
+
_require_sqlseed_ai()
|
|
1086
|
+
_conn_or_404(conn_id)
|
|
1087
|
+
try:
|
|
1088
|
+
_require_ai_export("sqlseed_ai.runtime", "build_ai_config")
|
|
1089
|
+
except ImportError as exc:
|
|
1090
|
+
raise HTTPException(status_code=503, detail=ai_import_failure()) from exc
|
|
1091
|
+
try:
|
|
1092
|
+
job = state.create_job(conn_id, kind="auto_heal", label="auto-heal")
|
|
1093
|
+
except KeyError as exc:
|
|
1094
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
1095
|
+
except ConnectionBusyError as exc:
|
|
1096
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
1097
|
+
try:
|
|
1098
|
+
start_background(target=_run_auto_heal_job, args=(conn_id, job.job_id, req), category="job")
|
|
1099
|
+
except Exception as exc:
|
|
1100
|
+
state.complete_job(job.job_id, error=_error_detail(exc))
|
|
1101
|
+
raise HTTPException(status_code=503, detail="无法启动分析任务,请重试。") from exc
|
|
1102
|
+
return {"job_id": job.job_id}
|