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.
Files changed (87) hide show
  1. sqlseed_web/AGENTS.md +106 -0
  2. sqlseed_web/__init__.py +24 -0
  3. sqlseed_web/__main__.py +8 -0
  4. sqlseed_web/_application.py +205 -0
  5. sqlseed_web/ai_settings.py +290 -0
  6. sqlseed_web/api.py +1102 -0
  7. sqlseed_web/app.py +26 -0
  8. sqlseed_web/managed_worker.py +184 -0
  9. sqlseed_web/operation_errors.py +42 -0
  10. sqlseed_web/plugin_environment.py +231 -0
  11. sqlseed_web/plugin_management.py +322 -0
  12. sqlseed_web/plugin_process.py +131 -0
  13. sqlseed_web/runtime_lifecycle.py +130 -0
  14. sqlseed_web/runtime_session.py +97 -0
  15. sqlseed_web/settings_environment.py +368 -0
  16. sqlseed_web/sqlite_target.py +86 -0
  17. sqlseed_web/state.py +348 -0
  18. sqlseed_web/static/AGENTS.md +180 -0
  19. sqlseed_web/static/ai.css +57 -0
  20. sqlseed_web/static/configs.css +50 -0
  21. sqlseed_web/static/date-picker.css +230 -0
  22. sqlseed_web/static/disclosure.css +149 -0
  23. sqlseed_web/static/graph-clarity.css +103 -0
  24. sqlseed_web/static/index.html +29 -0
  25. sqlseed_web/static/js/api.js +228 -0
  26. sqlseed_web/static/js/app.js +97 -0
  27. sqlseed_web/static/js/dropdown.js +432 -0
  28. sqlseed_web/static/js/filepicker.js +203 -0
  29. sqlseed_web/static/js/genform.js +1066 -0
  30. sqlseed_web/static/js/labels.js +195 -0
  31. sqlseed_web/static/js/pages/browse.js +209 -0
  32. sqlseed_web/static/js/pages/configs.js +424 -0
  33. sqlseed_web/static/js/pages/connect.js +332 -0
  34. sqlseed_web/static/js/pages/heal.js +395 -0
  35. sqlseed_web/static/js/pages/meta.js +110 -0
  36. sqlseed_web/static/js/pages/runs.js +293 -0
  37. sqlseed_web/static/js/pages/settings.js +942 -0
  38. sqlseed_web/static/js/pages/wizard.js +751 -0
  39. sqlseed_web/static/js/pages/workbench.js +3123 -0
  40. sqlseed_web/static/js/tree.js +126 -0
  41. sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
  42. sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
  43. sqlseed_web/static/js/workbench/ai-stream.js +116 -0
  44. sqlseed_web/static/js/workbench/ai.js +888 -0
  45. sqlseed_web/static/js/workbench/connection.js +508 -0
  46. sqlseed_web/static/js/workbench/date-picker.js +445 -0
  47. sqlseed_web/static/js/workbench/dependency-view.js +119 -0
  48. sqlseed_web/static/js/workbench/editor.js +1236 -0
  49. sqlseed_web/static/js/workbench/focus.js +11 -0
  50. sqlseed_web/static/js/workbench/graph-layout.js +332 -0
  51. sqlseed_web/static/js/workbench/graph.js +970 -0
  52. sqlseed_web/static/js/workbench/guidance.js +29 -0
  53. sqlseed_web/static/js/workbench/model.js +124 -0
  54. sqlseed_web/static/js/workbench/plugin-management.js +512 -0
  55. sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
  56. sqlseed_web/static/js/workbench/preview.js +572 -0
  57. sqlseed_web/static/js/workbench/provider-guide.js +33 -0
  58. sqlseed_web/static/js/workbench/recovery.js +28 -0
  59. sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
  60. sqlseed_web/static/js/workbench/session.js +174 -0
  61. sqlseed_web/static/js/workbench/table-data.js +186 -0
  62. sqlseed_web/static/js/workbench/ui.js +262 -0
  63. sqlseed_web/static/navigation.css +92 -0
  64. sqlseed_web/static/preview.css +29 -0
  65. sqlseed_web/static/runs.css +53 -0
  66. sqlseed_web/static/scrollbars.css +42 -0
  67. sqlseed_web/static/settings.css +108 -0
  68. sqlseed_web/static/style.css +3382 -0
  69. sqlseed_web/static/table-data.css +27 -0
  70. sqlseed_web/static/workbench.css +509 -0
  71. sqlseed_web/supervised_plugins.py +173 -0
  72. sqlseed_web/supervisor.py +238 -0
  73. sqlseed_web/workbench.py +381 -0
  74. sqlseed_web/workbench_ai.py +887 -0
  75. sqlseed_web/workbench_ai_relations.py +285 -0
  76. sqlseed_web/workbench_ai_stream.py +172 -0
  77. sqlseed_web/workbench_data.py +163 -0
  78. sqlseed_web/workbench_execution.py +199 -0
  79. sqlseed_web/workbench_runtime.py +1218 -0
  80. sqlseed_web/workbench_schema.py +277 -0
  81. sqlseed_web/workbench_store.py +458 -0
  82. sqlseed_web/worker_control.py +192 -0
  83. sqlseed_web-0.2.4.dist-info/METADATA +105 -0
  84. sqlseed_web-0.2.4.dist-info/RECORD +87 -0
  85. sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
  86. sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
  87. 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}