sql-dialect-master 1.1.2__tar.gz → 1.1.3__tar.gz
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.
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/PKG-INFO +11 -3
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/README.md +8 -1
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/api/main.py +38 -9
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/api/middleware.py +34 -15
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/api/rate_limit_store.py +19 -2
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/api/readiness.py +25 -7
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/cache.py +12 -2
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/config.py +36 -13
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/metadata.py +2 -2
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/core_extract.py +26 -4
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/templates.py +2 -2
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/orchestrator.py +30 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/renderer.py +40 -5
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/templates.py +16 -0
- sql_dialect_master-1.1.3/backend/core/nl2sql_pipeline/validation.py +76 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/p1_sql_scanner.py +18 -6
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/parser.py +50 -2
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/transpiler.py +76 -24
- sql_dialect_master-1.1.3/backend/frontend_entrypoint.py +22 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/design_tokens.py +7 -3
- sql_dialect_master-1.1.3/frontend/core/fingerprints.py +24 -0
- sql_dialect_master-1.1.3/frontend/core/sql_inputs.py +29 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/state.py +81 -26
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/viewmodels.py +22 -2
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/convert.py +104 -16
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/diff.py +15 -2
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/functions.py +2 -3
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/history.py +9 -6
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/lineage.py +31 -14
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/nl2sql.py +13 -3
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/query_analysis.py +12 -4
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/runtime.py +3 -3
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/templates.py +18 -1
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/ui/command_palette.py +21 -2
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/pyproject.toml +13 -2
- sql_dialect_master-1.1.3/sdm_local_v2.py +219 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/sql_dialect_master.egg-info/PKG-INFO +11 -3
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/sql_dialect_master.egg-info/SOURCES.txt +5 -0
- sql_dialect_master-1.1.3/sql_dialect_master.egg-info/entry_points.txt +2 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/sql_dialect_master.egg-info/requires.txt +2 -1
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/sql_dialect_master.egg-info/top_level.txt +1 -0
- sql_dialect_master-1.1.2/backend/core/nl2sql_pipeline/validation.py +0 -28
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/LICENSE +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/api/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/column_hint_validation.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/constants.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/exceptions.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/function_call_scanner.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/function_semantics.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/functions_db.json +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/functions_lookup.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/aggregations.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/boolean_conditions.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/evidence.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/mappings.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/relations.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/tokenizer.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_legacy.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/aggregations.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/clause_extract.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/dialect.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/intent.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/ir.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/models.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/predicates.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/resolution.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/suggestions.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/p2_data_validation.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/post_processor.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/rules.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/semantic_diff.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/type_mapping.json +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/type_mapping.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/utils/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/utils/validators.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/app_context_v2.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/escaping.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/navigation.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/styles.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/themes.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/core/type_status.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/settings.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/pages/types.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/templates_v2.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/ui/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/ui/diff.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/ui/editor.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/frontend/ui/status.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/setup.cfg +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/sql_dialect_master.egg-info/dependency_links.txt +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql-dialect-master
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.3
|
|
4
4
|
Summary: SQL Dialect Master - Multi-database SQL conversion engine
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
7
|
License-File: LICENSE
|
|
8
|
-
Requires-Dist: sqlglot
|
|
8
|
+
Requires-Dist: sqlglot<31.0.0,>=30.18.0
|
|
9
9
|
Requires-Dist: streamlit>=1.38.0
|
|
10
10
|
Requires-Dist: fastapi>=0.115.0
|
|
11
11
|
Requires-Dist: uvicorn>=0.30.0
|
|
@@ -16,6 +16,7 @@ Requires-Dist: hypothesis>=6.112.0; extra == "dev"
|
|
|
16
16
|
Requires-Dist: pytest>=8.3.0; extra == "dev"
|
|
17
17
|
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
|
|
18
18
|
Requires-Dist: httpx>=0.27.0; extra == "dev"
|
|
19
|
+
Requires-Dist: requests>=2.31.0; extra == "dev"
|
|
19
20
|
Provides-Extra: qa
|
|
20
21
|
Requires-Dist: ruff>=0.6; extra == "qa"
|
|
21
22
|
Requires-Dist: bandit>=1.7; extra == "qa"
|
|
@@ -31,7 +32,7 @@ Dynamic: license-file
|
|
|
31
32
|
|
|
32
33
|
Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
|
|
33
34
|
|
|
34
|
-
Current release: 1.1.
|
|
35
|
+
Current release: 1.1.3
|
|
35
36
|
|
|
36
37
|
[](https://www.python.org/downloads/)
|
|
37
38
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -95,6 +96,13 @@ python -m pip install -e ".[dev,semantic]"
|
|
|
95
96
|
streamlit run sdm_local_v2.py
|
|
96
97
|
```
|
|
97
98
|
|
|
99
|
+
After installing the package, the same workspace can be launched from any
|
|
100
|
+
working directory with:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
sdm-streamlit
|
|
104
|
+
```
|
|
105
|
+
|
|
98
106
|
Open http://localhost:8501 in your browser.
|
|
99
107
|
|
|
100
108
|
### Run FastAPI Backend
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
|
|
4
4
|
|
|
5
|
-
Current release: 1.1.
|
|
5
|
+
Current release: 1.1.3
|
|
6
6
|
|
|
7
7
|
[](https://www.python.org/downloads/)
|
|
8
8
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -66,6 +66,13 @@ python -m pip install -e ".[dev,semantic]"
|
|
|
66
66
|
streamlit run sdm_local_v2.py
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
After installing the package, the same workspace can be launched from any
|
|
70
|
+
working directory with:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
sdm-streamlit
|
|
74
|
+
```
|
|
75
|
+
|
|
69
76
|
Open http://localhost:8501 in your browser.
|
|
70
77
|
|
|
71
78
|
### Run FastAPI Backend
|
|
@@ -11,6 +11,7 @@ Enterprise-grade multi-database SQL conversion API:
|
|
|
11
11
|
Exact counts are derived from the canonical data files at runtime —
|
|
12
12
|
see backend.core.metadata — and must not be restated here.
|
|
13
13
|
"""
|
|
14
|
+
import asyncio
|
|
14
15
|
import logging
|
|
15
16
|
import time
|
|
16
17
|
from datetime import datetime
|
|
@@ -19,6 +20,7 @@ from typing import Any, Dict, List, Optional
|
|
|
19
20
|
from fastapi import FastAPI, HTTPException, Query, Request
|
|
20
21
|
from fastapi.middleware.cors import CORSMiddleware
|
|
21
22
|
from fastapi.responses import JSONResponse
|
|
23
|
+
from starlette.concurrency import run_in_threadpool
|
|
22
24
|
from pydantic import BaseModel, ConfigDict, Field
|
|
23
25
|
|
|
24
26
|
from backend.core.config import get_dialect_api_info, settings, setup_logging
|
|
@@ -32,10 +34,27 @@ from backend.core.type_mapping import TypeMapper
|
|
|
32
34
|
|
|
33
35
|
from backend.api.readiness import health_probe_response
|
|
34
36
|
|
|
35
|
-
# Configure logging for the application
|
|
36
|
-
|
|
37
|
+
# Configure logging for the application from the validated settings object.
|
|
38
|
+
# Keep an INFO fallback for defensive compatibility with custom settings
|
|
39
|
+
# objects, while normal AppSettings values are resolved from SDM_LOG_LEVEL.
|
|
40
|
+
_log_level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
|
41
|
+
logger = setup_logging(level=_log_level, log_file=settings.log_file or None)
|
|
37
42
|
api_logger = logging.getLogger(__name__)
|
|
38
43
|
|
|
44
|
+
|
|
45
|
+
async def _run_cpu_bound(func, *args):
|
|
46
|
+
"""Run parser/transpiler work off-loop with the configured timeout."""
|
|
47
|
+
try:
|
|
48
|
+
return await asyncio.wait_for(
|
|
49
|
+
run_in_threadpool(func, *args),
|
|
50
|
+
timeout=settings.request_timeout,
|
|
51
|
+
)
|
|
52
|
+
except asyncio.TimeoutError as exc:
|
|
53
|
+
raise HTTPException(
|
|
54
|
+
status_code=504,
|
|
55
|
+
detail="Request processing timed out",
|
|
56
|
+
) from exc
|
|
57
|
+
|
|
39
58
|
# API metadata
|
|
40
59
|
API_VERSION = settings.api_version
|
|
41
60
|
API_TITLE = settings.api_title
|
|
@@ -199,7 +218,10 @@ class ConvertRequest(BaseModel):
|
|
|
199
218
|
sql: str = Field(..., description="Source SQL statement to convert")
|
|
200
219
|
source_dialect: str = Field(..., description="Source database dialect")
|
|
201
220
|
target_dialect: str = Field(..., description="Target database dialect")
|
|
202
|
-
pretty: bool = Field(
|
|
221
|
+
pretty: bool = Field(
|
|
222
|
+
settings.transpiler_pretty_default,
|
|
223
|
+
description="Format output SQL with indentation",
|
|
224
|
+
)
|
|
203
225
|
|
|
204
226
|
model_config = ConfigDict(json_schema_extra={
|
|
205
227
|
"example": {
|
|
@@ -402,11 +424,14 @@ async def convert_sql(request: ConvertRequest):
|
|
|
402
424
|
detail=f"Unsupported target dialect: {request.target_dialect}. Supported: {SUPPORTED_DIALECTS}"
|
|
403
425
|
)
|
|
404
426
|
|
|
405
|
-
|
|
427
|
+
# sqlglot parsing/transpilation is CPU-bound. Keep it off the event loop
|
|
428
|
+
# so one large request cannot stall unrelated async requests.
|
|
429
|
+
result = await _run_cpu_bound(
|
|
430
|
+
transpiler.transpile,
|
|
406
431
|
request.sql,
|
|
407
432
|
source_dialect,
|
|
408
433
|
target_dialect,
|
|
409
|
-
request.pretty
|
|
434
|
+
request.pretty,
|
|
410
435
|
)
|
|
411
436
|
return ConvertResponse(
|
|
412
437
|
success=result.success,
|
|
@@ -431,7 +456,10 @@ async def parse_sql(request: ParseRequest):
|
|
|
431
456
|
"""
|
|
432
457
|
dialect = normalize_dialect(request.dialect)
|
|
433
458
|
parser = SQLParser(dialect)
|
|
434
|
-
|
|
459
|
+
parsed = await _run_cpu_bound(parser.parse, request.sql)
|
|
460
|
+
if not parsed.success:
|
|
461
|
+
raise HTTPException(status_code=422, detail=parsed.error or "SQL parsing failed")
|
|
462
|
+
elements = await _run_cpu_bound(parser.extract_elements, request.sql)
|
|
435
463
|
return {
|
|
436
464
|
"success": True,
|
|
437
465
|
"dialect": dialect,
|
|
@@ -458,7 +486,7 @@ async def list_functions(
|
|
|
458
486
|
if search:
|
|
459
487
|
functions = func_encyclopedia.search(search, limit)
|
|
460
488
|
elif category:
|
|
461
|
-
functions = func_encyclopedia.list_by_category(category)
|
|
489
|
+
functions = func_encyclopedia.list_by_category(category)[:limit]
|
|
462
490
|
else:
|
|
463
491
|
functions = func_encyclopedia.functions[:limit]
|
|
464
492
|
|
|
@@ -598,7 +626,8 @@ async def generate_sql(request: NL2SQLRequest):
|
|
|
598
626
|
"dotted identifier"
|
|
599
627
|
),
|
|
600
628
|
)
|
|
601
|
-
result =
|
|
629
|
+
result = await _run_cpu_bound(
|
|
630
|
+
nl2sql_generator.generate,
|
|
602
631
|
request.text,
|
|
603
632
|
dialect,
|
|
604
633
|
request.table_hint,
|
|
@@ -611,7 +640,7 @@ async def generate_sql(request: NL2SQLRequest):
|
|
|
611
640
|
dialect=dialect,
|
|
612
641
|
explanation=result.explanation,
|
|
613
642
|
confidence=result.confidence,
|
|
614
|
-
suggestions=result.suggestions
|
|
643
|
+
suggestions=list(result.suggestions[: settings.nl2sql_max_suggestions])
|
|
615
644
|
)
|
|
616
645
|
|
|
617
646
|
@app.get("/health", tags=["system"])
|
|
@@ -75,7 +75,11 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
75
75
|
|
|
76
76
|
async def _read_bounded_body(self, request: Request) -> Response | None:
|
|
77
77
|
"""Consume and cache request bodies without trusting Content-Length."""
|
|
78
|
-
|
|
78
|
+
# HEAD has no response body by definition and some ASGI servers do
|
|
79
|
+
# not expose a request stream for it. GET bodies are unusual but
|
|
80
|
+
# still need the same bound so an oversized payload cannot bypass
|
|
81
|
+
# request-size protection by changing the HTTP method.
|
|
82
|
+
if request.method == "HEAD":
|
|
79
83
|
return None
|
|
80
84
|
content_length = request.headers.get("content-length")
|
|
81
85
|
if content_length:
|
|
@@ -83,6 +87,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
83
87
|
declared_length = int(content_length)
|
|
84
88
|
except ValueError:
|
|
85
89
|
return JSONResponse(status_code=400, content={"success": False, "error": {"code": 400, "message": "Invalid Content-Length"}, "timestamp": datetime.now().isoformat()})
|
|
90
|
+
if declared_length < 0:
|
|
91
|
+
return JSONResponse(status_code=400, content={"success": False, "error": {"code": 400, "message": "Invalid Content-Length"}, "timestamp": datetime.now().isoformat()})
|
|
86
92
|
if declared_length > self.max_body_bytes:
|
|
87
93
|
return JSONResponse(status_code=413, content={"success": False, "error": {"code": 413, "message": "Request body exceeds the maximum allowed size", "max_bytes": self.max_body_bytes}, "timestamp": datetime.now().isoformat()})
|
|
88
94
|
|
|
@@ -97,6 +103,28 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
97
103
|
return None
|
|
98
104
|
|
|
99
105
|
async def dispatch(self, request: Request, call_next) -> Response:
|
|
106
|
+
is_health_probe = (
|
|
107
|
+
request.url.path in {"/health", "/", "/ready", "/health/deep"}
|
|
108
|
+
and request.method in {"GET", "HEAD"}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Apply rate limiting before JSON/body parsing. This prevents a
|
|
112
|
+
# client from spending unbounded CPU on invalid payloads while still
|
|
113
|
+
# keeping the request-size guard active when rate limiting is disabled.
|
|
114
|
+
rate_limit_checked = False
|
|
115
|
+
remaining = reset_time = None
|
|
116
|
+
if self.enabled and not is_health_probe:
|
|
117
|
+
client_id = self._get_client_id(request)
|
|
118
|
+
is_allowed, remaining, reset_time = self.limiter.is_allowed(client_id)
|
|
119
|
+
rate_limit_checked = True
|
|
120
|
+
if not is_allowed:
|
|
121
|
+
logger.warning("Rate limit exceeded for client: %s", _sanitize_log_value(client_id))
|
|
122
|
+
return JSONResponse(
|
|
123
|
+
status_code=429,
|
|
124
|
+
content={"success": False, "error": {"code": 429, "message": "Rate limit exceeded", "retry_after": reset_time}, "timestamp": datetime.now().isoformat()},
|
|
125
|
+
headers={"X-RateLimit-Limit": str(self.limiter._requests_per_window), "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": str(reset_time), "Retry-After": str(reset_time)},
|
|
126
|
+
)
|
|
127
|
+
|
|
100
128
|
body_error = await self._read_bounded_body(request)
|
|
101
129
|
if body_error is not None:
|
|
102
130
|
return body_error
|
|
@@ -118,24 +146,15 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
118
146
|
|
|
119
147
|
if not self.enabled:
|
|
120
148
|
return await call_next(request)
|
|
121
|
-
if
|
|
149
|
+
if is_health_probe:
|
|
122
150
|
if request.url.path in {"/ready", "/health/deep"}:
|
|
123
151
|
return await health_probe_response(request)
|
|
124
152
|
return await call_next(request)
|
|
125
|
-
|
|
126
|
-
client_id = self._get_client_id(request)
|
|
127
|
-
is_allowed, remaining, reset_time = self.limiter.is_allowed(client_id)
|
|
128
|
-
if not is_allowed:
|
|
129
|
-
logger.warning("Rate limit exceeded for client: %s", _sanitize_log_value(client_id))
|
|
130
|
-
return JSONResponse(
|
|
131
|
-
status_code=429,
|
|
132
|
-
content={"success": False, "error": {"code": 429, "message": "Rate limit exceeded", "retry_after": reset_time}, "timestamp": datetime.now().isoformat()},
|
|
133
|
-
headers={"X-RateLimit-Limit": str(self.limiter._requests_per_window), "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": str(reset_time), "Retry-After": str(reset_time)},
|
|
134
|
-
)
|
|
135
153
|
response = await call_next(request)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
154
|
+
if rate_limit_checked:
|
|
155
|
+
response.headers["X-RateLimit-Limit"] = str(self.limiter._requests_per_window)
|
|
156
|
+
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
|
157
|
+
response.headers["X-RateLimit-Reset"] = str(reset_time)
|
|
139
158
|
return response
|
|
140
159
|
|
|
141
160
|
def _get_client_id(self, request: Request) -> str:
|
|
@@ -65,6 +65,8 @@ class InMemoryRateLimitStore:
|
|
|
65
65
|
class RedisRateLimitStore:
|
|
66
66
|
"""Atomic fixed-window store backed by Redis for multi-worker deployments."""
|
|
67
67
|
|
|
68
|
+
DEFAULT_TIMEOUT_SECONDS = 3.0
|
|
69
|
+
|
|
68
70
|
_SCRIPT = """
|
|
69
71
|
local count = redis.call('INCR', KEYS[1])
|
|
70
72
|
if count == 1 then
|
|
@@ -74,14 +76,29 @@ class RedisRateLimitStore:
|
|
|
74
76
|
return {count, ttl}
|
|
75
77
|
"""
|
|
76
78
|
|
|
77
|
-
def __init__(
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
url: str,
|
|
82
|
+
namespace: str = "sdm:rate-limit:",
|
|
83
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
84
|
+
) -> None:
|
|
78
85
|
if not url:
|
|
79
86
|
raise ValueError("Redis URL is required when rate-limit backend is redis")
|
|
87
|
+
if timeout_seconds <= 0:
|
|
88
|
+
raise ValueError("timeout_seconds must be positive")
|
|
80
89
|
try:
|
|
81
90
|
import redis
|
|
82
91
|
except ImportError as exc:
|
|
83
92
|
raise RuntimeError("Redis rate limiting requires the optional 'redis' package") from exc
|
|
84
|
-
|
|
93
|
+
# Bound both connection establishment and command execution. A
|
|
94
|
+
# stalled Redis node must not block API startup or every request that
|
|
95
|
+
# checks the distributed rate-limit store.
|
|
96
|
+
self._client = redis.Redis.from_url(
|
|
97
|
+
url,
|
|
98
|
+
decode_responses=False,
|
|
99
|
+
socket_connect_timeout=timeout_seconds,
|
|
100
|
+
socket_timeout=timeout_seconds,
|
|
101
|
+
)
|
|
85
102
|
self._client.ping()
|
|
86
103
|
self._script = self._client.register_script(self._SCRIPT)
|
|
87
104
|
self._namespace = namespace
|
|
@@ -7,6 +7,7 @@ import logging
|
|
|
7
7
|
import os
|
|
8
8
|
import secrets
|
|
9
9
|
import time
|
|
10
|
+
import uuid
|
|
10
11
|
import weakref
|
|
11
12
|
from datetime import datetime
|
|
12
13
|
from typing import Any
|
|
@@ -16,6 +17,8 @@ from fastapi.responses import JSONResponse
|
|
|
16
17
|
|
|
17
18
|
logger = logging.getLogger(__name__)
|
|
18
19
|
|
|
20
|
+
from backend.core.config import settings
|
|
21
|
+
|
|
19
22
|
_READINESS_CACHE_TTL_SECONDS = 5.0
|
|
20
23
|
_READINESS_TIMEOUT_SECONDS = 3.0
|
|
21
24
|
_READINESS_LOCKS: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = weakref.WeakKeyDictionary()
|
|
@@ -26,22 +29,37 @@ _HEALTH_PROBE_PATHS = {"/ready", "/health/deep"}
|
|
|
26
29
|
|
|
27
30
|
def _probe_allowed(request: Request) -> bool:
|
|
28
31
|
"""Allow health probes only from loopback or with the configured probe secret."""
|
|
29
|
-
|
|
32
|
+
configured = settings.health_probe_token
|
|
33
|
+
token = configured.get_secret_value().strip() if configured else ""
|
|
34
|
+
# Keep process-level test/dev overrides effective after module import;
|
|
35
|
+
# production configuration still comes from the validated singleton above
|
|
36
|
+
# (including values loaded from .env).
|
|
37
|
+
if not token:
|
|
38
|
+
token = os.environ.get("SDM_HEALTH_PROBE_TOKEN", "").strip()
|
|
30
39
|
supplied = request.headers.get("X-Health-Probe-Token", "")
|
|
31
40
|
if token and supplied and secrets.compare_digest(supplied, token):
|
|
32
41
|
return True
|
|
42
|
+
# Once a token is configured, require it even for loopback callers. This
|
|
43
|
+
# avoids trusting an unverified X-Forwarded-For chain in front of a proxy.
|
|
44
|
+
if token:
|
|
45
|
+
return False
|
|
33
46
|
client = request.client
|
|
34
47
|
return bool(client and client.host in {"127.0.0.1", "::1", "localhost"})
|
|
35
48
|
|
|
36
49
|
|
|
37
|
-
def _health_probe_denied_response() -> JSONResponse:
|
|
50
|
+
def _health_probe_denied_response(request: Request) -> JSONResponse:
|
|
38
51
|
"""Return a stable denial response for public health probe requests."""
|
|
52
|
+
request_id = getattr(request.state, "request_id", None) or str(uuid.uuid4())
|
|
53
|
+
headers = {"X-Request-ID": request_id}
|
|
54
|
+
content = {
|
|
55
|
+
"success": False,
|
|
56
|
+
"error": {"code": 403, "message": "health probe access denied"},
|
|
57
|
+
}
|
|
58
|
+
content["request_id"] = request_id
|
|
39
59
|
return JSONResponse(
|
|
40
60
|
status_code=403,
|
|
41
|
-
content=
|
|
42
|
-
|
|
43
|
-
"error": {"code": 403, "message": "health probe access denied"},
|
|
44
|
-
},
|
|
61
|
+
content=content,
|
|
62
|
+
headers=headers,
|
|
45
63
|
)
|
|
46
64
|
|
|
47
65
|
|
|
@@ -50,7 +68,7 @@ async def health_probe_response(request: Request) -> JSONResponse:
|
|
|
50
68
|
if request.url.path not in _HEALTH_PROBE_PATHS or request.method not in {"GET", "HEAD"}:
|
|
51
69
|
raise ValueError("health_probe_response called for a non-probe request")
|
|
52
70
|
if not _probe_allowed(request):
|
|
53
|
-
return _health_probe_denied_response()
|
|
71
|
+
return _health_probe_denied_response(request)
|
|
54
72
|
if request.url.path == "/ready":
|
|
55
73
|
return await readiness_response(request)
|
|
56
74
|
return await deep_health_response(request)
|
|
@@ -48,6 +48,8 @@ class TTLCache:
|
|
|
48
48
|
"""
|
|
49
49
|
if max_size <= 0:
|
|
50
50
|
raise ValueError("max_size must be greater than 0")
|
|
51
|
+
if ttl < 0:
|
|
52
|
+
raise ValueError("ttl must be greater than or equal to 0")
|
|
51
53
|
self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
|
|
52
54
|
self._max_size = max_size
|
|
53
55
|
self._ttl = ttl
|
|
@@ -222,10 +224,18 @@ class TTLCache:
|
|
|
222
224
|
except Exception as e:
|
|
223
225
|
logger.error(f"Background cleanup error: {e}")
|
|
224
226
|
|
|
225
|
-
thread = threading.Thread(target=cleanup_loop, daemon=True, name="CacheCleanup")
|
|
226
227
|
with self._lock:
|
|
228
|
+
# Check, assign and start under the same lock. Keeping the
|
|
229
|
+
# assignment inside the critical section closes the TOCTOU window
|
|
230
|
+
# where two concurrent callers both observe no live thread and
|
|
231
|
+
# each start a cleanup worker.
|
|
232
|
+
if self._cleanup_thread is not None and self._cleanup_thread.is_alive():
|
|
233
|
+
raise RuntimeError(
|
|
234
|
+
"background cleanup is already running for this cache"
|
|
235
|
+
)
|
|
236
|
+
thread = threading.Thread(target=cleanup_loop, daemon=True, name="CacheCleanup")
|
|
227
237
|
self._cleanup_thread = thread
|
|
228
|
-
|
|
238
|
+
thread.start()
|
|
229
239
|
logger.info(f"Started background cache cleanup (interval={interval}s)")
|
|
230
240
|
return thread
|
|
231
241
|
|
|
@@ -14,7 +14,7 @@ import sys
|
|
|
14
14
|
from enum import Enum
|
|
15
15
|
from typing import Any, Dict, List, Literal, Optional, TypedDict
|
|
16
16
|
|
|
17
|
-
from pydantic import Field, SecretStr
|
|
17
|
+
from pydantic import Field, SecretStr, field_validator, model_validator
|
|
18
18
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
19
19
|
|
|
20
20
|
# =============================================================================
|
|
@@ -220,18 +220,20 @@ class AppSettings(BaseSettings):
|
|
|
220
220
|
"http://localhost:8501,http://localhost:8000,"
|
|
221
221
|
"http://127.0.0.1:8501,http://127.0.0.1:8000"
|
|
222
222
|
)
|
|
223
|
-
max_batch_size: int = 100
|
|
224
|
-
request_timeout: int = 30
|
|
223
|
+
max_batch_size: int = Field(100, gt=0)
|
|
224
|
+
request_timeout: int = Field(30, gt=0)
|
|
225
225
|
|
|
226
226
|
# Cache settings
|
|
227
227
|
cache_enabled: bool = True
|
|
228
|
-
|
|
228
|
+
# Zero intentionally disables expiry (TTLCache contract); negatives are
|
|
229
|
+
# invalid because they are indistinguishable from that mode at runtime.
|
|
230
|
+
cache_ttl: int = Field(300, ge=0)
|
|
229
231
|
cache_max_size: int = Field(1000, gt=0)
|
|
230
232
|
|
|
231
233
|
# Rate limiting
|
|
232
234
|
rate_limit_enabled: bool = True
|
|
233
|
-
rate_limit_requests: int = 100
|
|
234
|
-
rate_limit_window: int = 60
|
|
235
|
+
rate_limit_requests: int = Field(100, gt=0)
|
|
236
|
+
rate_limit_window: int = Field(60, gt=0)
|
|
235
237
|
# Storage backend selection: strict lowercase values; no case or
|
|
236
238
|
# whitespace normalization — a Literal rejects any other spelling at
|
|
237
239
|
# settings construction instead of silently coercing it.
|
|
@@ -243,9 +245,9 @@ class AppSettings(BaseSettings):
|
|
|
243
245
|
redis_url: Optional[SecretStr] = None
|
|
244
246
|
|
|
245
247
|
# NL2SQL settings
|
|
246
|
-
nl2sql_confidence_threshold: float = 0.6
|
|
247
|
-
nl2sql_max_suggestions: int = 5
|
|
248
|
-
nl2sql_max_input_length: int = 8192
|
|
248
|
+
nl2sql_confidence_threshold: float = Field(0.6, ge=0.0, le=1.0)
|
|
249
|
+
nl2sql_max_suggestions: int = Field(5, ge=0)
|
|
250
|
+
nl2sql_max_input_length: int = Field(8192, gt=0)
|
|
249
251
|
|
|
250
252
|
# SQL Transpiler / Parser settings
|
|
251
253
|
#
|
|
@@ -254,12 +256,16 @@ class AppSettings(BaseSettings):
|
|
|
254
256
|
# to ``transpiler_max_sql_length``. Business code must read the
|
|
255
257
|
# settings object — never ``os.getenv`` directly.
|
|
256
258
|
transpiler_pretty_default: bool = True
|
|
257
|
-
transpiler_max_sql_length: int = 100000
|
|
258
|
-
parser_max_sql_length: int =
|
|
259
|
+
transpiler_max_sql_length: int = Field(100000, gt=0)
|
|
260
|
+
parser_max_sql_length: Optional[int] = Field(None, gt=0)
|
|
259
261
|
|
|
260
262
|
# Function encyclopedia
|
|
261
|
-
function_search_limit: int = 50
|
|
262
|
-
function_fuzzy_threshold: float = 0.4
|
|
263
|
+
function_search_limit: int = Field(50, gt=0)
|
|
264
|
+
function_fuzzy_threshold: float = Field(0.4, ge=0.0, le=1.0)
|
|
265
|
+
|
|
266
|
+
# Readiness probes. SecretStr keeps the value out of settings reprs and
|
|
267
|
+
# structured logs while still allowing deployment through SDM_ env vars.
|
|
268
|
+
health_probe_token: Optional[SecretStr] = None
|
|
263
269
|
|
|
264
270
|
# Security settings
|
|
265
271
|
security_check_enabled: bool = True
|
|
@@ -268,6 +274,23 @@ class AppSettings(BaseSettings):
|
|
|
268
274
|
# Logging settings
|
|
269
275
|
log_level: str = "INFO"
|
|
270
276
|
log_file: str = ""
|
|
277
|
+
|
|
278
|
+
@model_validator(mode="after")
|
|
279
|
+
def _apply_parser_length_fallback(self) -> "AppSettings":
|
|
280
|
+
"""Keep parser and transpiler limits aligned unless explicitly set."""
|
|
281
|
+
if self.parser_max_sql_length is None:
|
|
282
|
+
self.parser_max_sql_length = self.transpiler_max_sql_length
|
|
283
|
+
return self
|
|
284
|
+
|
|
285
|
+
@field_validator("log_level", mode="before")
|
|
286
|
+
@classmethod
|
|
287
|
+
def _normalize_log_level(cls, value: str) -> str:
|
|
288
|
+
normalized = str(value).strip().upper()
|
|
289
|
+
if normalized not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
|
|
290
|
+
raise ValueError(
|
|
291
|
+
"log_level must be one of DEBUG, INFO, WARNING, ERROR, CRITICAL"
|
|
292
|
+
)
|
|
293
|
+
return normalized
|
|
271
294
|
|
|
272
295
|
def to_dict(self) -> Dict[str, Any]:
|
|
273
296
|
"""Convert settings to dictionary."""
|
|
@@ -17,8 +17,8 @@ Design rules:
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
# ── Release version facts (sole source of truth) ────────────────────────
|
|
20
|
-
PROJECT_VERSION = "1.1.
|
|
21
|
-
API_VERSION = "1.1.
|
|
20
|
+
PROJECT_VERSION = "1.1.3"
|
|
21
|
+
API_VERSION = "1.1.3"
|
|
22
22
|
# Frontend v2 architecture — independent of the product release version.
|
|
23
23
|
FRONTEND_ARCHITECTURE_VERSION = 2
|
|
24
24
|
|
{sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/core_extract.py
RENAMED
|
@@ -9,6 +9,12 @@ import re
|
|
|
9
9
|
from typing import Dict, List, Optional
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
# Generated SQL is intended to be reviewed and parameterized by a caller.
|
|
13
|
+
# Refuse unbounded/absurd row counts before integer conversion (which can
|
|
14
|
+
# raise ValueError for thousands of digits on modern Python versions).
|
|
15
|
+
MAX_NL2SQL_LIMIT = 1_000_000
|
|
16
|
+
|
|
17
|
+
|
|
12
18
|
def detect_operation(text: str, keywords: Dict[str, str]) -> str:
|
|
13
19
|
"""Detect SQL operation type from text.
|
|
14
20
|
|
|
@@ -101,14 +107,30 @@ def extract_limit(text: str) -> Optional[int]:
|
|
|
101
107
|
'前10', 'top 10', 'limit 10', '10条', '10行'.
|
|
102
108
|
"""
|
|
103
109
|
patterns = [
|
|
104
|
-
r"前\s*(
|
|
105
|
-
r"first\s*(
|
|
106
|
-
r"(
|
|
110
|
+
r"前\s*(-?\d+)", r"top\s*(-?\d+)", r"limit\s*(-?\d+)",
|
|
111
|
+
r"first\s*(-?\d+)", r"(-?\d+)\s*条", r"(-?\d+)\s*个",
|
|
112
|
+
r"(-?\d+)\s*行", r"(-?\d+)\s*rows?", r"only\s*(-?\d+)",
|
|
107
113
|
]
|
|
108
114
|
for pattern in patterns:
|
|
109
115
|
match = re.search(pattern, text, re.IGNORECASE)
|
|
110
116
|
if match:
|
|
111
|
-
|
|
117
|
+
raw_value = match.group(1)
|
|
118
|
+
# Keep the conversion bounded and deterministic. Raising here is
|
|
119
|
+
# handled by the generator as a client-facing failed generation;
|
|
120
|
+
# silently dropping an invalid limit could produce an unbounded
|
|
121
|
+
# query.
|
|
122
|
+
if raw_value.startswith("-"):
|
|
123
|
+
raise ValueError("LIMIT cannot be negative")
|
|
124
|
+
if len(raw_value) > len(str(MAX_NL2SQL_LIMIT)):
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"LIMIT exceeds maximum supported value of {MAX_NL2SQL_LIMIT}"
|
|
127
|
+
)
|
|
128
|
+
value = int(raw_value)
|
|
129
|
+
if value > MAX_NL2SQL_LIMIT:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"LIMIT exceeds maximum supported value of {MAX_NL2SQL_LIMIT}"
|
|
132
|
+
)
|
|
133
|
+
return value
|
|
112
134
|
return None
|
|
113
135
|
|
|
114
136
|
|
{sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_components/templates.py
RENAMED
|
@@ -86,7 +86,7 @@ class QueryTemplate:
|
|
|
86
86
|
DEFAULT_QUERY_TEMPLATES = [
|
|
87
87
|
QueryTemplate(
|
|
88
88
|
name="top_n_query",
|
|
89
|
-
pattern=rf"(?:查询|获取|get|show|find)?\s*(?:前|top|bottom|最低|最少|lowest|smallest)\s*(
|
|
89
|
+
pattern=rf"(?:查询|获取|get|show|find)?\s*(?:前|top|bottom|最低|最少|lowest|smallest)\s*(-?\d+)\s*(?:个|条|名)?\s*(.{{1,{_TEMPLATE_CAPTURE_LIMIT}}}?)(?:按|by)?\s*(.{{1,{_TEMPLATE_CAPTURE_LIMIT}}})?\s*(?:排序|排列|order)?",
|
|
90
90
|
sql_template="SELECT * FROM {table} ORDER BY {order_col} DESC LIMIT {n}",
|
|
91
91
|
priority=90,
|
|
92
92
|
),
|
|
@@ -98,7 +98,7 @@ DEFAULT_QUERY_TEMPLATES = [
|
|
|
98
98
|
),
|
|
99
99
|
QueryTemplate(
|
|
100
100
|
name="time_range_query",
|
|
101
|
-
pattern=r"(?:查询|获取|get|find)?\s*(?:最近|过去|last|past)\s*(
|
|
101
|
+
pattern=r"(?:查询|获取|get|find)?\s*(?:最近|过去|last|past)\s*(-?\d+)\s*(天|周|月|年|days?|weeks?|months?|years?)(?:的|内的)?\s*(.+)",
|
|
102
102
|
sql_template="SELECT * FROM {table} WHERE {date_col} >= DATE_SUB(CURRENT_DATE, {interval})",
|
|
103
103
|
priority=85,
|
|
104
104
|
),
|
{sql_dialect_master-1.1.2 → sql_dialect_master-1.1.3}/backend/core/nl2sql_pipeline/orchestrator.py
RENAMED
|
@@ -219,6 +219,36 @@ class PipelineNL2SQLGenerator:
|
|
|
219
219
|
suggestions=["Please shorten the natural-language query and try again."],
|
|
220
220
|
evidence=GenerationEvidence(),
|
|
221
221
|
)
|
|
222
|
+
# Validate an explicitly requested row limit before the extraction
|
|
223
|
+
# stage. This turns huge numeric literals into a controlled failed
|
|
224
|
+
# generation instead of an uncaught int-conversion exception (500 at
|
|
225
|
+
# the API boundary), and prevents silently dropping the limit.
|
|
226
|
+
try:
|
|
227
|
+
self._extract_limit(text)
|
|
228
|
+
except ValueError as exc:
|
|
229
|
+
return NL2SQLResult(
|
|
230
|
+
success=False,
|
|
231
|
+
input_text=text,
|
|
232
|
+
dialect=normalized_dialect,
|
|
233
|
+
explanation=str(exc),
|
|
234
|
+
confidence=0.0,
|
|
235
|
+
suggestions=["Use a LIMIT value no greater than 1000000."],
|
|
236
|
+
evidence=GenerationEvidence(),
|
|
237
|
+
)
|
|
238
|
+
if re.search(
|
|
239
|
+
r"(?:最近|过去|last|past)\s*-\d+\s*(?:天|周|月|年|days?|weeks?|months?|years?)",
|
|
240
|
+
text,
|
|
241
|
+
re.IGNORECASE,
|
|
242
|
+
):
|
|
243
|
+
return NL2SQLResult(
|
|
244
|
+
success=False,
|
|
245
|
+
input_text=text,
|
|
246
|
+
dialect=normalized_dialect,
|
|
247
|
+
explanation="Date interval cannot be negative",
|
|
248
|
+
confidence=0.0,
|
|
249
|
+
suggestions=["Use a non-negative date interval."],
|
|
250
|
+
evidence=GenerationEvidence(),
|
|
251
|
+
)
|
|
222
252
|
dialect = normalized_dialect
|
|
223
253
|
table_hint = normalized_table_hint
|
|
224
254
|
if column_hints_error:
|