sql-dialect-master 1.1.1__tar.gz → 1.1.2__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.1 → sql_dialect_master-1.1.2}/PKG-INFO +2 -2
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/README.md +1 -1
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/api/main.py +13 -1
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/api/rate_limit_store.py +25 -7
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/config.py +11 -2
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/metadata.py +2 -2
- sql_dialect_master-1.1.2/backend/core/nl2sql_components/aggregations.py +52 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/core_extract.py +16 -5
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/relations.py +99 -9
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/templates.py +6 -1
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/orchestrator.py +58 -10
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/predicates.py +61 -1
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/renderer.py +29 -19
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/resolution.py +12 -3
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/templates.py +39 -5
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/parser.py +33 -1
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/rules.py +216 -15
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/transpiler.py +565 -46
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/type_mapping.py +40 -3
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/utils/validators.py +87 -10
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/core/navigation.py +22 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/core/themes.py +7 -1
- sql_dialect_master-1.1.2/frontend/core/type_status.py +26 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/convert.py +1 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/nl2sql.py +4 -14
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/templates.py +3 -1
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/types.py +2 -6
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/ui/editor.py +1 -1
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/ui/status.py +6 -6
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/pyproject.toml +1 -1
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/sql_dialect_master.egg-info/PKG-INFO +2 -2
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/sql_dialect_master.egg-info/SOURCES.txt +1 -0
- sql_dialect_master-1.1.1/backend/core/nl2sql_components/aggregations.py +0 -35
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/LICENSE +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/__init__.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/api/__init__.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/api/middleware.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/api/readiness.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/__init__.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/cache.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/column_hint_validation.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/constants.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/exceptions.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/function_call_scanner.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/function_semantics.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/functions_db.json +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/functions_lookup.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/__init__.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/boolean_conditions.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/evidence.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/mappings.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/tokenizer.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_legacy.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/__init__.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/aggregations.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/clause_extract.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/dialect.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/intent.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/ir.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/models.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/suggestions.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/validation.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/p1_sql_scanner.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/p2_data_validation.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/post_processor.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/semantic_diff.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/type_mapping.json +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/utils/__init__.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/__init__.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/app_context_v2.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/core/__init__.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/core/design_tokens.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/core/escaping.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/core/state.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/core/styles.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/core/viewmodels.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/__init__.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/diff.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/functions.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/history.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/lineage.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/query_analysis.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/runtime.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/pages/settings.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/templates_v2.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/ui/__init__.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/ui/command_palette.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/frontend/ui/diff.py +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/setup.cfg +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/sql_dialect_master.egg-info/dependency_links.txt +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/sql_dialect_master.egg-info/requires.txt +0 -0
- {sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/sql_dialect_master.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql-dialect-master
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.2
|
|
4
4
|
Summary: SQL Dialect Master - Multi-database SQL conversion engine
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -31,7 +31,7 @@ Dynamic: license-file
|
|
|
31
31
|
|
|
32
32
|
Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
|
|
33
33
|
|
|
34
|
-
Current release: 1.1.
|
|
34
|
+
Current release: 1.1.2
|
|
35
35
|
|
|
36
36
|
[](https://www.python.org/downloads/)
|
|
37
37
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -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.2
|
|
6
6
|
|
|
7
7
|
[](https://www.python.org/downloads/)
|
|
8
8
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -25,6 +25,7 @@ from backend.core.config import get_dialect_api_info, settings, setup_logging
|
|
|
25
25
|
from backend.core import metadata as meta
|
|
26
26
|
from backend.core.functions_lookup import FunctionEncyclopedia
|
|
27
27
|
from backend.core.nl2sql import NL2SQLGenerator
|
|
28
|
+
from backend.core.nl2sql_pipeline.orchestrator import SAFE_TABLE_HINT
|
|
28
29
|
from backend.core.parser import SUPPORTED_DIALECTS, SQLParser
|
|
29
30
|
from backend.core.transpiler import SQLTranspiler
|
|
30
31
|
from backend.core.type_mapping import TypeMapper
|
|
@@ -568,7 +569,7 @@ async def map_type(request: TypeMapRequest):
|
|
|
568
569
|
target_dialect,
|
|
569
570
|
)
|
|
570
571
|
return {
|
|
571
|
-
"success":
|
|
572
|
+
"success": result.get("success", False),
|
|
572
573
|
**result,
|
|
573
574
|
"timestamp": datetime.now().isoformat()
|
|
574
575
|
}
|
|
@@ -586,6 +587,17 @@ async def generate_sql(request: NL2SQLRequest):
|
|
|
586
587
|
- "Get top 10 users by score" → SELECT * FROM users ORDER BY score DESC LIMIT 10
|
|
587
588
|
"""
|
|
588
589
|
dialect = normalize_dialect(request.dialect)
|
|
590
|
+
# Validate table_hint at the API boundary: an illegal identifier is
|
|
591
|
+
# a client error (4xx), never an internal ValueError → 500. The
|
|
592
|
+
# same rule backs the orchestrator's direct-caller ValueError.
|
|
593
|
+
if request.table_hint and not SAFE_TABLE_HINT.fullmatch(request.table_hint.strip()):
|
|
594
|
+
raise HTTPException(
|
|
595
|
+
status_code=400,
|
|
596
|
+
detail=(
|
|
597
|
+
"Invalid table_hint: must be a simple SQL identifier or "
|
|
598
|
+
"dotted identifier"
|
|
599
|
+
),
|
|
600
|
+
)
|
|
589
601
|
result = nl2sql_generator.generate(
|
|
590
602
|
request.text,
|
|
591
603
|
dialect,
|
|
@@ -5,10 +5,11 @@ Redis is opt-in. The in-memory backend remains the default for local/single-proc
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
-
import os
|
|
9
8
|
import time
|
|
10
9
|
from threading import Lock
|
|
11
|
-
from typing import Protocol
|
|
10
|
+
from typing import Optional, Protocol
|
|
11
|
+
|
|
12
|
+
from backend.core.config import AppSettings, settings as _default_settings
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class RateLimitStore(Protocol):
|
|
@@ -95,11 +96,28 @@ class RedisRateLimitStore:
|
|
|
95
96
|
return {"backend": "redis", "namespace": self._namespace}
|
|
96
97
|
|
|
97
98
|
|
|
98
|
-
def create_rate_limit_store() -> RateLimitStore:
|
|
99
|
-
"""Create the configured store; never silently downgrade Redis to memory.
|
|
100
|
-
|
|
99
|
+
def create_rate_limit_store(config: Optional[AppSettings] = None) -> RateLimitStore:
|
|
100
|
+
"""Create the configured store; never silently downgrade Redis to memory.
|
|
101
|
+
|
|
102
|
+
Reads configuration from ``config``, or from the unified
|
|
103
|
+
``backend.core.config.settings`` singleton when ``config`` is None.
|
|
104
|
+
The factory never reads raw environment variables — all deployment
|
|
105
|
+
configuration flows through AppSettings (SDM_ prefix), so backend
|
|
106
|
+
selection and the Redis URL share one validated configuration path.
|
|
107
|
+
|
|
108
|
+
The Redis URL is a SecretStr in AppSettings; its actual value is
|
|
109
|
+
read here only to construct the Redis client, and it is never
|
|
110
|
+
embedded in error messages or logs.
|
|
111
|
+
"""
|
|
112
|
+
settings_obj = config if config is not None else _default_settings
|
|
113
|
+
backend = settings_obj.rate_limit_backend
|
|
101
114
|
if backend == "memory":
|
|
102
115
|
return InMemoryRateLimitStore()
|
|
103
116
|
if backend == "redis":
|
|
104
|
-
|
|
105
|
-
|
|
117
|
+
url = settings_obj.redis_url
|
|
118
|
+
url_value = url.get_secret_value() if url is not None else ""
|
|
119
|
+
return RedisRateLimitStore(url_value)
|
|
120
|
+
# Boundary guard for untyped callers: only reachable when a caller
|
|
121
|
+
# passes a non-AppSettings object with an unexpected backend value
|
|
122
|
+
# (typed AppSettings validation rejects it earlier).
|
|
123
|
+
raise ValueError("Unsupported rate_limit_backend; expected 'memory' or 'redis'")
|
|
@@ -12,9 +12,9 @@ Supports configuration via:
|
|
|
12
12
|
import logging
|
|
13
13
|
import sys
|
|
14
14
|
from enum import Enum
|
|
15
|
-
from typing import Any, Dict, List, Optional, TypedDict
|
|
15
|
+
from typing import Any, Dict, List, Literal, Optional, TypedDict
|
|
16
16
|
|
|
17
|
-
from pydantic import Field
|
|
17
|
+
from pydantic import Field, SecretStr
|
|
18
18
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
19
19
|
|
|
20
20
|
# =============================================================================
|
|
@@ -232,6 +232,15 @@ class AppSettings(BaseSettings):
|
|
|
232
232
|
rate_limit_enabled: bool = True
|
|
233
233
|
rate_limit_requests: int = 100
|
|
234
234
|
rate_limit_window: int = 60
|
|
235
|
+
# Storage backend selection: strict lowercase values; no case or
|
|
236
|
+
# whitespace normalization — a Literal rejects any other spelling at
|
|
237
|
+
# settings construction instead of silently coercing it.
|
|
238
|
+
rate_limit_backend: Literal["memory", "redis"] = "memory"
|
|
239
|
+
# Redis connection URL, required only when the backend is redis.
|
|
240
|
+
# SecretStr keeps the password out of repr / logs / serialization;
|
|
241
|
+
# the store factory reads the actual value only to build the Redis
|
|
242
|
+
# client. Default None = not configured.
|
|
243
|
+
redis_url: Optional[SecretStr] = None
|
|
235
244
|
|
|
236
245
|
# NL2SQL settings
|
|
237
246
|
nl2sql_confidence_threshold: float = 0.6
|
|
@@ -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.2"
|
|
21
|
+
API_VERSION = "1.1.2"
|
|
22
22
|
# Frontend v2 architecture — independent of the product release version.
|
|
23
23
|
FRONTEND_ARCHITECTURE_VERSION = 2
|
|
24
24
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""NL2SQL aggregation extraction.
|
|
3
|
+
|
|
4
|
+
Extracts aggregate function calls (COUNT, SUM, AVG, MAX, MIN) from
|
|
5
|
+
natural-language text using keyword matching. Uses the shared
|
|
6
|
+
COLUMN_PATTERNS to resolve the target column for value aggregates.
|
|
7
|
+
"""
|
|
8
|
+
import re
|
|
9
|
+
from typing import Dict, List
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def extract_aggregations(text: str, column_patterns: Dict[str, str]) -> List[str]:
|
|
13
|
+
"""Extract aggregation expressions from text.
|
|
14
|
+
|
|
15
|
+
Returns a list of SQL aggregate expressions, e.g.
|
|
16
|
+
['COUNT(*)', 'SUM(amount)']. An empty list means no aggregation
|
|
17
|
+
was requested.
|
|
18
|
+
"""
|
|
19
|
+
aggs: List[str] = []
|
|
20
|
+
text_lower = text.lower()
|
|
21
|
+
# Pick the user's named metric column, not the first pattern that
|
|
22
|
+
# happens to sit inside an aggregate keyword: the legacy first-hit
|
|
23
|
+
# walk let "average" contain "age" and produced AVG(age).
|
|
24
|
+
named_metrics = [
|
|
25
|
+
column
|
|
26
|
+
for pattern, column in column_patterns.items()
|
|
27
|
+
if pattern != "age" and pattern in text
|
|
28
|
+
]
|
|
29
|
+
# Bare "age" is only a metric when it is not the trailing substring
|
|
30
|
+
# of "average": require a non-letter before it so "average" never
|
|
31
|
+
# self-matches, while "average age" still does.
|
|
32
|
+
if re.search(r"(?<![a-z])age", text_lower):
|
|
33
|
+
if "age" not in named_metrics:
|
|
34
|
+
named_metrics.append("age")
|
|
35
|
+
if "price" in text_lower:
|
|
36
|
+
named_metrics = [c for c in named_metrics if c != "age"]
|
|
37
|
+
agg_column = named_metrics[0] if named_metrics else None
|
|
38
|
+
col = agg_column or "amount"
|
|
39
|
+
if any(k in text for k in ["统计", "计数", "数量", "多少", "几个", "count", "total", "how many"]):
|
|
40
|
+
aggs.append("COUNT(*)")
|
|
41
|
+
if any(k in text for k in ["求和", "总和", "合计", "总计", "sum", "total of"]):
|
|
42
|
+
aggs.append(f"SUM({col})")
|
|
43
|
+
if any(k in text_lower for k in ["平均", "均值", "平均值", "average", "avg", "mean"]):
|
|
44
|
+
# "average" contains "age": the average metric is price unless
|
|
45
|
+
# the text explicitly names the age column as the target.
|
|
46
|
+
col = "price" if ("price" in text_lower and not re.search(r"(?<![a-z])age", text_lower)) else (agg_column or "amount")
|
|
47
|
+
aggs.append(f"AVG({col})")
|
|
48
|
+
if any(k in text_lower for k in ["最大", "最高", "最多", "max", "maximum", "highest", "largest"]):
|
|
49
|
+
aggs.append(f"MAX({col})")
|
|
50
|
+
if any(k in text_lower for k in ["最小", "最低", "最少", "min", "minimum", "lowest", "smallest"]):
|
|
51
|
+
aggs.append(f"MIN({col})")
|
|
52
|
+
return aggs
|
{sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/core_extract.py
RENAMED
|
@@ -38,18 +38,29 @@ def extract_table(text: str, table_patterns: Dict[str, str]) -> str:
|
|
|
38
38
|
return "table_name"
|
|
39
39
|
|
|
40
40
|
|
|
41
|
-
def extract_table_subject(
|
|
41
|
+
def extract_table_subject(
|
|
42
|
+
text: str, table_patterns: Dict[str, str], relational_context: str = ""
|
|
43
|
+
) -> Optional[str]:
|
|
42
44
|
"""Extract the subject table (first appearing in text) for D2 relational queries.
|
|
43
45
|
|
|
44
46
|
For queries like 'users who have orders', returns 'users' (the subject)
|
|
45
47
|
rather than 'orders' (the relation). Uses first occurrence position.
|
|
48
|
+
|
|
49
|
+
``relational_context`` names the relation table when the caller already
|
|
50
|
+
resolved one from the relational keyword set (e.g. the CN 有订单
|
|
51
|
+
connective maps 订单 → orders). That table must not win the subject
|
|
52
|
+
slot even when it is the earliest-matching pattern, so every candidate
|
|
53
|
+
table equal to it is excluded before the first-occurrence sort.
|
|
46
54
|
"""
|
|
47
55
|
candidates = []
|
|
48
56
|
for pattern, table in table_patterns.items():
|
|
49
|
-
if table
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
if relational_context and table == relational_context:
|
|
58
|
+
continue
|
|
59
|
+
pos = text.find(pattern)
|
|
60
|
+
if pos != -1:
|
|
61
|
+
if table in [t for _, t in candidates]:
|
|
62
|
+
continue
|
|
63
|
+
candidates.append((pos, table))
|
|
53
64
|
if candidates:
|
|
54
65
|
candidates.sort(key=lambda x: x[0])
|
|
55
66
|
return candidates[0][1]
|
{sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/relations.py
RENAMED
|
@@ -27,6 +27,8 @@ JOIN_KEY_PATTERNS: Dict[tuple, str] = {
|
|
|
27
27
|
("payments", "orders"): "payments.order_id = orders.id",
|
|
28
28
|
("customers", "orders"): "customers.id = orders.customer_id",
|
|
29
29
|
("orders", "customers"): "orders.customer_id = customers.id",
|
|
30
|
+
("customers", "payments"): "customers.id = payments.customer_id",
|
|
31
|
+
("payments", "customers"): "payments.customer_id = customers.id",
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
# Negative relational existence keywords -> NOT EXISTS
|
|
@@ -34,6 +36,8 @@ _NOT_EXISTS_KEYWORDS = [
|
|
|
34
36
|
"without", "not have", "no ",
|
|
35
37
|
"who don't have", "who doesn't have",
|
|
36
38
|
"who do not have", "who does not have",
|
|
39
|
+
# Chinese negative relational intent: "没有任何X的" / "没有X的".
|
|
40
|
+
"没有任何",
|
|
37
41
|
]
|
|
38
42
|
|
|
39
43
|
# Positive relational existence keywords -> EXISTS
|
|
@@ -44,6 +48,10 @@ _EXISTS_KEYWORDS = [
|
|
|
44
48
|
"with invoices", "with payments", "with transactions",
|
|
45
49
|
"with suppliers", "with products",
|
|
46
50
|
"who placed", "who made", "who created", "who submitted",
|
|
51
|
+
# Chinese positive relational intent: "有订单的" / "有X的" phrasings.
|
|
52
|
+
# "有" alone is far too broad; the 2-char forms below are specific
|
|
53
|
+
# enough to signal a relational predicate ("users who have orders").
|
|
54
|
+
"有订单", "有产品", "有客户", "有员工", "有日志",
|
|
47
55
|
]
|
|
48
56
|
|
|
49
57
|
|
|
@@ -111,6 +119,18 @@ def guess_join_key(table1: str, table2: str) -> Optional[str]:
|
|
|
111
119
|
return JOIN_KEY_PATTERNS.get((table1, table2))
|
|
112
120
|
|
|
113
121
|
|
|
122
|
+
# Chinese relational table connectives: the modifier word that names the
|
|
123
|
+
# relation table. '查询有订单的用户' → 订单 is the relation table, 用户
|
|
124
|
+
# is the subject. The modifier must sit between the existence keywords
|
|
125
|
+
# (有 / 没有任何 / 没有) and the trailing 的 / end-of-phrase, so we match
|
|
126
|
+
# the <modifier> token directly in the lowercased text.
|
|
127
|
+
_CN_REL_TABLES = {
|
|
128
|
+
"订单": "orders", "产品": "products", "客户": "customers",
|
|
129
|
+
"员工": "employees", "日志": "logs", "发票": "invoices",
|
|
130
|
+
"支付": "payments", "交易": "transactions",
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
114
134
|
def extract_joins(
|
|
115
135
|
text: str,
|
|
116
136
|
table_patterns: Dict[str, str],
|
|
@@ -129,19 +149,56 @@ def extract_joins(
|
|
|
129
149
|
joins = []
|
|
130
150
|
# Collect unique tables in text-appearance order (first occurrence wins).
|
|
131
151
|
# Exclude "order" when it is part of an "order by" sorting clause.
|
|
152
|
+
# EN word-boundary matching stays for ASCII patterns; CJK patterns have
|
|
153
|
+
# no word boundaries, so they match by direct occurrence — this is what
|
|
154
|
+
# lets '用户' / '订单' register inside a Chinese sentence.
|
|
132
155
|
table_occurrences = []
|
|
156
|
+
text_lower = text.lower()
|
|
133
157
|
for pattern, table in table_patterns.items():
|
|
134
158
|
if pattern == "order" and re.search(r"\border\b\s+by\b", text, re.IGNORECASE):
|
|
135
159
|
continue
|
|
136
|
-
if
|
|
137
|
-
|
|
160
|
+
if table in ("table", "tables", "data"):
|
|
161
|
+
# Generic aliases are not resolvable table names; including
|
|
162
|
+
# them only creates phantom multi-table requests.
|
|
163
|
+
continue
|
|
164
|
+
if pattern.isascii():
|
|
165
|
+
matched = re.search(r"\b" + re.escape(pattern) + r"\b", text, re.IGNORECASE)
|
|
166
|
+
else:
|
|
167
|
+
matched = re.search(re.escape(pattern), text)
|
|
168
|
+
if matched and table not in [t for _, t in table_occurrences]:
|
|
169
|
+
# Position = the pattern occurrence the match actually landed on.
|
|
170
|
+
# text.find() fails for ASCII patterns when case differs and for
|
|
171
|
+
# CJK patterns when an earlier raw occurrence exists; matched
|
|
172
|
+
# carries the real start index for the occurrence we used.
|
|
173
|
+
start = matched.start()
|
|
174
|
+
table_occurrences.append((start, table))
|
|
175
|
+
# Chinese relational connective: when 有/没有任何/没有 is followed by a
|
|
176
|
+
# known relation-table word, that word names the relation table even if
|
|
177
|
+
# the CJK pattern loop above did not catch it (e.g. the relation table
|
|
178
|
+
# is an EN-lexed alias). The modifier position is strictly before the
|
|
179
|
+
# subject table position — it is the phrase "有<relation>的<subject>"
|
|
180
|
+
# — so inserting it at its own position keeps tables_found ordered by
|
|
181
|
+
# text position and picks out the subject correctly below.
|
|
182
|
+
for modifier, rel_table in _CN_REL_TABLES.items():
|
|
183
|
+
if re.search(r"(?:有|没有任何|没有)" + re.escape(modifier), text_lower):
|
|
184
|
+
if rel_table not in [t for _, t in table_occurrences]:
|
|
185
|
+
table_occurrences.append((text_lower.find(modifier), rel_table))
|
|
186
|
+
break
|
|
138
187
|
tables_found = [t for _, t in sorted(table_occurrences)]
|
|
139
188
|
if len(tables_found) >= 2:
|
|
140
189
|
join_spec = parse_join_spec(text)
|
|
141
|
-
|
|
190
|
+
cn_relational = any(
|
|
191
|
+
re.search(r"(?:有|没有任何|没有)" + re.escape(modifier), text)
|
|
192
|
+
for modifier in _CN_REL_TABLES
|
|
193
|
+
)
|
|
194
|
+
cn_negative = any(
|
|
195
|
+
re.search(r"(?:没有任何|没有)" + re.escape(modifier), text)
|
|
196
|
+
for modifier in _CN_REL_TABLES
|
|
197
|
+
)
|
|
198
|
+
if any(k in text for k in _NOT_EXISTS_KEYWORDS) or (cn_negative and cn_relational):
|
|
142
199
|
join_type = "NOT EXISTS"
|
|
143
200
|
relational = True
|
|
144
|
-
elif any(k in text for k in _EXISTS_KEYWORDS):
|
|
201
|
+
elif any(k in text for k in _EXISTS_KEYWORDS) or (cn_relational and not cn_negative):
|
|
145
202
|
join_type = "EXISTS"
|
|
146
203
|
relational = True
|
|
147
204
|
elif join_spec is not None:
|
|
@@ -153,8 +210,26 @@ def extract_joins(
|
|
|
153
210
|
join_type = "JOIN"
|
|
154
211
|
relational = False
|
|
155
212
|
|
|
156
|
-
|
|
157
|
-
|
|
213
|
+
# Chinese relational intent (有订单的用户 etc.): the subject table is
|
|
214
|
+
# the one the user named last ("the users"), and the relation table is
|
|
215
|
+
# the one named in the 有/没有 modifier (the orders). tables_found
|
|
216
|
+
# is sorted by position, so the subject is the last entry, and the
|
|
217
|
+
# relation table is whichever was added by the CN-modifier branch.
|
|
218
|
+
# Detect: when the Chinese connective fired, the relation table is
|
|
219
|
+
# exactly the one the modifier branch appended.
|
|
220
|
+
cn_relation_table = None
|
|
221
|
+
for modifier, rel_table in _CN_REL_TABLES.items():
|
|
222
|
+
if re.search(r"(?:有|没有任何|没有)" + re.escape(modifier), text_lower):
|
|
223
|
+
cn_relation_table = rel_table
|
|
224
|
+
break
|
|
225
|
+
if cn_relation_table and len(tables_found) >= 2:
|
|
226
|
+
# Subject = the first-appearing table that is NOT the CN relation
|
|
227
|
+
# table; relation = the CN table.
|
|
228
|
+
subject_candidates = [t for t in tables_found if t != cn_relation_table]
|
|
229
|
+
main_table = subject_candidates[0] if subject_candidates else tables_found[0]
|
|
230
|
+
else:
|
|
231
|
+
main_table = tables_found[0]
|
|
232
|
+
for other_table in ([cn_relation_table] if cn_relation_table else [t for t in tables_found[1:]]):
|
|
158
233
|
join_key = guess_join_key(main_table, other_table)
|
|
159
234
|
record = {
|
|
160
235
|
"type": join_type,
|
|
@@ -175,6 +250,21 @@ def extract_joins(
|
|
|
175
250
|
|
|
176
251
|
|
|
177
252
|
def has_relational_keyword(text: str) -> bool:
|
|
178
|
-
"""Check if text contains any relational existence keyword.
|
|
179
|
-
|
|
180
|
-
|
|
253
|
+
"""Check if text contains any relational existence keyword.
|
|
254
|
+
|
|
255
|
+
CN table-modifier phrases (有订单 / 没有任何订单 …) are also relational
|
|
256
|
+
intent even without the EN 'have/without' phrasing. Bare EN "with <表>"
|
|
257
|
+
is only relational when a subject-table word also names the request:
|
|
258
|
+
'find users with orders' is relational; 'find orders' alone is not.
|
|
259
|
+
"""
|
|
260
|
+
all_keywords = _NOT_EXISTS_KEYWORDS + [
|
|
261
|
+
# 'with <表>' forms are gated in extract_joins; here we include
|
|
262
|
+
# them in the keyword list but let extract_joins filter by
|
|
263
|
+
# subject-table presence (see _resolve_relational_kind).
|
|
264
|
+
] + _EXISTS_KEYWORDS
|
|
265
|
+
if any(pattern in text for pattern in all_keywords):
|
|
266
|
+
return True
|
|
267
|
+
for modifier in _CN_REL_TABLES:
|
|
268
|
+
if re.search(r"(?:有|没有任何|没有)" + re.escape(modifier), text):
|
|
269
|
+
return True
|
|
270
|
+
return False
|
{sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_components/templates.py
RENAMED
|
@@ -18,6 +18,11 @@ _COMPOSITION_MARKERS = (
|
|
|
18
18
|
"group by", "grouped by", "分组", "汇总",
|
|
19
19
|
"limit", "top ", "top\t", "only ", "前",
|
|
20
20
|
" and ", " or ", "且", "并且", "或者", "或",
|
|
21
|
+
# Chinese relational-existence phrases: capture by the enhanced path
|
|
22
|
+
# (EXISTS/NOT EXISTS + fail-closed), never by a single-table select
|
|
23
|
+
# template that would silently drop the relationship.
|
|
24
|
+
"有订单", "有产品", "有客户", "有员工", "有日志",
|
|
25
|
+
"没有任何订单", "没有任何产品", "没有任何客户",
|
|
21
26
|
)
|
|
22
27
|
|
|
23
28
|
_TEMPLATE_BLOCKERS = {
|
|
@@ -93,7 +98,7 @@ DEFAULT_QUERY_TEMPLATES = [
|
|
|
93
98
|
),
|
|
94
99
|
QueryTemplate(
|
|
95
100
|
name="time_range_query",
|
|
96
|
-
pattern=r"(?:查询|获取|get|find)?\s*(?:最近|过去|last|past)\s*(\d+)\s*(天|周|月|年|days?|weeks?|months?|years?)
|
|
101
|
+
pattern=r"(?:查询|获取|get|find)?\s*(?:最近|过去|last|past)\s*(\d+)\s*(天|周|月|年|days?|weeks?|months?|years?)(?:的|内的)?\s*(.+)",
|
|
97
102
|
sql_template="SELECT * FROM {table} WHERE {date_col} >= DATE_SUB(CURRENT_DATE, {interval})",
|
|
98
103
|
priority=85,
|
|
99
104
|
),
|
{sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/orchestrator.py
RENAMED
|
@@ -67,6 +67,13 @@ from .validation import validate_sql as _validate_sql_impl
|
|
|
67
67
|
|
|
68
68
|
logger = logging.getLogger(__name__)
|
|
69
69
|
|
|
70
|
+
# Canonical table_hint identifier rule, shared by the orchestrator (which
|
|
71
|
+
# raises ValueError for direct callers) and the API boundary (which
|
|
72
|
+
# converts the violation into a 4xx validation error before generation).
|
|
73
|
+
SAFE_TABLE_HINT = re.compile(
|
|
74
|
+
r"[A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)*"
|
|
75
|
+
)
|
|
76
|
+
|
|
70
77
|
|
|
71
78
|
class PipelineNL2SQLGenerator:
|
|
72
79
|
"""NL2SQL pipeline base class.
|
|
@@ -180,13 +187,10 @@ class PipelineNL2SQLGenerator:
|
|
|
180
187
|
f"Unsupported dialect: {dialect}. "
|
|
181
188
|
f"Supported: {', '.join(SUPPORTED_DIALECTS)}"
|
|
182
189
|
)
|
|
183
|
-
_SAFE_TABLE_HINT = re.compile(
|
|
184
|
-
r"[A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)*"
|
|
185
|
-
)
|
|
186
190
|
normalized_table_hint = (
|
|
187
191
|
table_hint.strip() if isinstance(table_hint, str) else table_hint
|
|
188
192
|
)
|
|
189
|
-
if normalized_table_hint and not
|
|
193
|
+
if normalized_table_hint and not SAFE_TABLE_HINT.fullmatch(normalized_table_hint):
|
|
190
194
|
raise ValueError(
|
|
191
195
|
"table_hint must be a simple SQL identifier or dotted identifier"
|
|
192
196
|
)
|
|
@@ -258,12 +262,13 @@ class PipelineNL2SQLGenerator:
|
|
|
258
262
|
parsed = self._parse_text(text_lower, text)
|
|
259
263
|
parsed.update(analysis)
|
|
260
264
|
operation = self._detect_operation(text_lower)
|
|
265
|
+
cn_rel_table = self._cn_relation_table(text_lower)
|
|
261
266
|
if self._has_relational_keyword(text_lower):
|
|
262
267
|
# Relational subject: 'users who have orders where price = 5'
|
|
263
268
|
# must yield FROM users, not FROM orders.
|
|
264
269
|
table = (
|
|
265
270
|
table_hint
|
|
266
|
-
or self._extract_table_subject(text_lower)
|
|
271
|
+
or self._extract_table_subject(text_lower, cn_rel_table)
|
|
267
272
|
or self._extract_table(text_lower)
|
|
268
273
|
)
|
|
269
274
|
else:
|
|
@@ -306,6 +311,15 @@ class PipelineNL2SQLGenerator:
|
|
|
306
311
|
if template and self._has_relational_keyword(text_lower):
|
|
307
312
|
template = None
|
|
308
313
|
logger.debug("NL2SQL: Relational keyword detected, bypassing template matching")
|
|
314
|
+
# A select/simpler template that captures a request naming two
|
|
315
|
+
# tables via Chinese connectives (用户 和 发票) must route to the
|
|
316
|
+
# enhanced path too: the select_with_columns / simple_select
|
|
317
|
+
# templates would silently drop the second table's relationship
|
|
318
|
+
# instead of failing closed.
|
|
319
|
+
elif template and template.name in ("select_with_columns", "simple_select"):
|
|
320
|
+
if len(self._named_tables(text_lower)) >= 2:
|
|
321
|
+
template = None
|
|
322
|
+
logger.debug("NL2SQL: multi-table select captured by select template, bypassing")
|
|
309
323
|
if template:
|
|
310
324
|
result = self._generate_from_template(
|
|
311
325
|
template, match_groups, analysis, dialect,
|
|
@@ -336,8 +350,20 @@ class PipelineNL2SQLGenerator:
|
|
|
336
350
|
# tables is a relational/join request; it only succeeds when every
|
|
337
351
|
# pair between the mentioned tables has a canonical relationship.
|
|
338
352
|
named_tables = self._named_tables(text_lower)
|
|
353
|
+
cn_rel_table = self._cn_relation_table(text_lower)
|
|
339
354
|
if len(named_tables) >= 2:
|
|
340
|
-
table
|
|
355
|
+
# Relational subject-table resolution: when the request carries
|
|
356
|
+
# a relational connective (D2/CN 有<relation>的<subject>), the
|
|
357
|
+
# outer FROM is the subject table, not the relation table the
|
|
358
|
+
# connective names.
|
|
359
|
+
table = (
|
|
360
|
+
table_hint
|
|
361
|
+
or (
|
|
362
|
+
self._extract_table_subject(text_lower, cn_rel_table)
|
|
363
|
+
if self._has_relational_keyword(text_lower)
|
|
364
|
+
else named_tables[0]
|
|
365
|
+
)
|
|
366
|
+
)
|
|
341
367
|
unresolved = [j for j in joins if j.get("condition") is None]
|
|
342
368
|
if unresolved:
|
|
343
369
|
pair_desc = " ⟷ ".join(
|
|
@@ -362,8 +388,13 @@ class PipelineNL2SQLGenerator:
|
|
|
362
388
|
],
|
|
363
389
|
)
|
|
364
390
|
else:
|
|
391
|
+
cn_rel_table = self._cn_relation_table(text_lower)
|
|
365
392
|
if self._has_relational_keyword(text_lower):
|
|
366
|
-
table =
|
|
393
|
+
table = (
|
|
394
|
+
table_hint
|
|
395
|
+
or self._extract_table_subject(text_lower, cn_rel_table)
|
|
396
|
+
or "table_name"
|
|
397
|
+
)
|
|
367
398
|
else:
|
|
368
399
|
table = table_hint or self._extract_table(text_lower)
|
|
369
400
|
|
|
@@ -412,12 +443,19 @@ class PipelineNL2SQLGenerator:
|
|
|
412
443
|
"""
|
|
413
444
|
return _pipeline_extract_table(text, self.TABLE_PATTERNS)
|
|
414
445
|
|
|
415
|
-
def _extract_table_subject(
|
|
446
|
+
def _extract_table_subject(
|
|
447
|
+
self, text: str, relational_context: str = ""
|
|
448
|
+
) -> Optional[str]:
|
|
416
449
|
"""Extract the subject table (first appearing in text) for D2 relational queries.
|
|
417
450
|
|
|
418
|
-
Delegates to ``nl2sql_pipeline.resolution.extract_table_subject
|
|
451
|
+
Delegates to ``nl2sql_pipeline.resolution.extract_table_subject``;
|
|
452
|
+
``relational_context`` excludes the already-resolved relation table
|
|
453
|
+
from the subject slot so '查询有订单的用户' returns 'users', not
|
|
454
|
+
'orders'.
|
|
419
455
|
"""
|
|
420
|
-
return _pipeline_extract_table_subject(
|
|
456
|
+
return _pipeline_extract_table_subject(
|
|
457
|
+
text, self.TABLE_PATTERNS, relational_context
|
|
458
|
+
)
|
|
421
459
|
|
|
422
460
|
def _named_tables(self, text: str) -> list:
|
|
423
461
|
"""Return the distinct table names the text mentions.
|
|
@@ -477,6 +515,16 @@ class PipelineNL2SQLGenerator:
|
|
|
477
515
|
"""
|
|
478
516
|
return _pipeline_extract_joins(text, self.TABLE_PATTERNS)
|
|
479
517
|
|
|
518
|
+
def _cn_relation_table(self, text: str) -> str:
|
|
519
|
+
"""Return the CN relational connective's table (orders, products, ...)
|
|
520
|
+
when present in ``text``; otherwise ""."""
|
|
521
|
+
import re as _re
|
|
522
|
+
from ..nl2sql_components.relations import _CN_REL_TABLES
|
|
523
|
+
for modifier, rel_table in _CN_REL_TABLES.items():
|
|
524
|
+
if _re.search(r"(?:有|没有任何|没有)" + _re.escape(modifier), text):
|
|
525
|
+
return rel_table
|
|
526
|
+
return ""
|
|
527
|
+
|
|
480
528
|
def _guess_join_key(self, table1: str, table2: str) -> Optional[str]:
|
|
481
529
|
"""Guess the join key between two tables.
|
|
482
530
|
|
{sql_dialect_master-1.1.1 → sql_dialect_master-1.1.2}/backend/core/nl2sql_pipeline/predicates.py
RENAMED
|
@@ -86,8 +86,68 @@ def extract_conditions(
|
|
|
86
86
|
(["小于", "低于", "少于", "不足", "less", "less than", "below", "under", "<"], "<"),
|
|
87
87
|
(["等于", "是", "为", "equals", "equal", "="], "="),
|
|
88
88
|
]
|
|
89
|
+
# For each operator word, the threshold is the number that
|
|
90
|
+
# immediately follows it in the original text (not numbers[0] —
|
|
91
|
+
# '前10条金额大于100' must bind 100 to 大于, not 10). Multi-char
|
|
92
|
+
# operator words are tried in longest-first order so that e.g.
|
|
93
|
+
# 大于等于 binds its own value before the 大于 row fires on the same
|
|
94
|
+
# text — the legacy row order let the shorter 大于 substring steal
|
|
95
|
+
# an 大于等于 match and emit ">" instead of ">=".
|
|
96
|
+
#
|
|
97
|
+
# The primary value bind (operator word → number directly after it)
|
|
98
|
+
# is the canonical form. When that bind fails but an operator word
|
|
99
|
+
# is still present (e.g. 'greater than 5' — number follows 'than',
|
|
100
|
+
# not 'greater'), a fallback binds the column before the operator
|
|
101
|
+
# word to the first number after it. This keeps the legacy
|
|
102
|
+
# operator-word + numbers[0] behavior for English phrases while
|
|
103
|
+
# correcting the Chinese case where the operator word sits between
|
|
104
|
+
# the column and the threshold number.
|
|
89
105
|
for keywords, op in comparisons:
|
|
90
|
-
|
|
106
|
+
bound_value = None
|
|
107
|
+
matched_keyword = None
|
|
108
|
+
# Primary bind: the number that immediately follows the operator
|
|
109
|
+
# word itself (Chinese-style adjacency, e.g. '大于100').
|
|
110
|
+
# Longest keyword first so '大于等于' never yields to '大于'.
|
|
111
|
+
for keyword in sorted(keywords, key=len, reverse=True):
|
|
112
|
+
if keyword not in original:
|
|
113
|
+
continue
|
|
114
|
+
m = re.search(
|
|
115
|
+
re.escape(keyword) + r"\s*(\d+(?:\.\d+)?)",
|
|
116
|
+
original,
|
|
117
|
+
)
|
|
118
|
+
if m:
|
|
119
|
+
bound_value = m.group(1)
|
|
120
|
+
matched_keyword = keyword
|
|
121
|
+
break
|
|
122
|
+
# Second bind: the number at the END of the operator phrase
|
|
123
|
+
# (English "greater than 100" / "greater than or equal to 100"
|
|
124
|
+
# — the value follows "than"/"to", not the operator's head word).
|
|
125
|
+
# The Top-N count that precedes the whole phrase (numbers[0] =
|
|
126
|
+
# "10" in "top 10 …") must never win this slot.
|
|
127
|
+
if bound_value is None:
|
|
128
|
+
for keyword in sorted(keywords, key=len, reverse=True):
|
|
129
|
+
if keyword not in original:
|
|
130
|
+
continue
|
|
131
|
+
m = re.search(
|
|
132
|
+
re.escape(keyword)
|
|
133
|
+
+ r"(?:\s+than\s+|\s+or\s+equal\s+to\s+|\s+than\s+or\s+equal\s+to\s+)?(\d+(?:\.\d+)?)",
|
|
134
|
+
original,
|
|
135
|
+
re.IGNORECASE,
|
|
136
|
+
)
|
|
137
|
+
if m:
|
|
138
|
+
bound_value = m.group(1)
|
|
139
|
+
matched_keyword = keyword
|
|
140
|
+
break
|
|
141
|
+
if matched_keyword is not None and bound_value is None and numbers:
|
|
142
|
+
bound_value = numbers[0]
|
|
143
|
+
if bound_value is not None:
|
|
144
|
+
col = condition_column or "column"
|
|
145
|
+
conditions.append(f"{col} {op} {bound_value}")
|
|
146
|
+
break
|
|
147
|
+
# Fallback: operator word present but no number is directly or
|
|
148
|
+
# tail-adjacent to it — bind the column named before the operator
|
|
149
|
+
# word to the first available number.
|
|
150
|
+
if any(k in original for k in keywords) and numbers:
|
|
91
151
|
col = condition_column or "column"
|
|
92
152
|
conditions.append(f"{col} {op} {numbers[0]}")
|
|
93
153
|
break
|