@roll-agent/octopus-agent 0.0.11 → 0.1.1

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.
@@ -5,6 +5,8 @@ import re
5
5
  from dataclasses import dataclass
6
6
  from typing import Any
7
7
 
8
+ from .schema_context_retriever import _schema_enums as merged_schema_enums
9
+
8
10
 
9
11
  class SqlGenerationError(RuntimeError):
10
12
  pass
@@ -18,6 +20,7 @@ class GeneratedSql:
18
20
  used_columns: list[str] | None = None
19
21
  used_joins: list[str] | None = None
20
22
  metric_refs: list[str] | None = None
23
+ clarification: dict[str, Any] | None = None
21
24
 
22
25
 
23
26
  @dataclass(frozen=True)
@@ -26,12 +29,14 @@ class TimeRange:
26
29
  alias: str
27
30
  start: str
28
31
  end: str
32
+ table_name: str = ""
29
33
 
30
34
 
31
35
  @dataclass(frozen=True)
32
36
  class FieldRef:
33
37
  name: str
34
38
  alias: str
39
+ table_name: str = ""
35
40
 
36
41
 
37
42
  @dataclass(frozen=True)
@@ -44,6 +49,28 @@ class SchemaValidationIssue:
44
49
  class LowQualitySqlIssue:
45
50
  code: str
46
51
  message: str
52
+ missing_items: tuple[str, ...] = ()
53
+ current_problem: str = ""
54
+ repair_hint: str = ""
55
+
56
+
57
+ _SELECT_AS_ALIAS_PATTERN = (
58
+ r"\s+as\s+(`[^`]+`|\"[^\"]+\"|'[^']+'|\w+)\s*$"
59
+ )
60
+
61
+ _FORBIDDEN_DML_KEYWORDS = re.compile(
62
+ r"\b(insert|update|delete|drop|alter|create|truncate)\b",
63
+ flags=re.IGNORECASE,
64
+ )
65
+
66
+
67
+ def _sql_without_string_literals(sql: str) -> str:
68
+ return re.sub(r"'([^']|'')*'", "''", sql)
69
+
70
+
71
+ def contains_forbidden_dml_keyword(sql: str) -> bool:
72
+ stripped = _sql_without_string_literals(sql.strip())
73
+ return _FORBIDDEN_DML_KEYWORDS.search(stripped) is not None
47
74
 
48
75
 
49
76
  def enforce_sql_rules(sql: str, *, max_limit: int) -> None:
@@ -52,7 +79,7 @@ def enforce_sql_rules(sql: str, *, max_limit: int) -> None:
52
79
  raise SqlGenerationError("SQL must be SELECT only")
53
80
  if ";" in normalized.rstrip(";"):
54
81
  raise SqlGenerationError("SQL must be a single statement")
55
- if re.search(r"\b(insert|update|delete|drop|alter|create|truncate)\b", normalized):
82
+ if contains_forbidden_dml_keyword(sql):
56
83
  raise SqlGenerationError("SQL contains a forbidden keyword")
57
84
  if re.search(r"\b(password|token|secret|credential|private_key)\b", normalized):
58
85
  raise SqlGenerationError("SQL contains a sensitive field")
@@ -61,8 +88,11 @@ def enforce_sql_rules(sql: str, *, max_limit: int) -> None:
61
88
  if ":scope." in normalized:
62
89
  raise SqlGenerationError("SQL must not contain scope placeholders")
63
90
  _enforce_text_filters_use_like(sql)
91
+ _enforce_join_on_uses_equality_keys(sql)
92
+ _enforce_no_aggregate_functions_in_row_filters(sql)
64
93
  _enforce_chinese_select_aliases(sql)
65
94
  _enforce_no_null_placeholder_select_items(sql)
95
+ _enforce_no_tautological_join_conditions(sql)
66
96
  limit = re.search(r"\blimit\s+(\d+)\b", normalized)
67
97
  if limit is None:
68
98
  raise SqlGenerationError("SQL must contain LIMIT")
@@ -70,6 +100,226 @@ def enforce_sql_rules(sql: str, *, max_limit: int) -> None:
70
100
  raise SqlGenerationError(f"SQL LIMIT must not exceed {max_limit}")
71
101
 
72
102
 
103
+ def build_schema_validation_error(
104
+ sql: str,
105
+ issues: list[SchemaValidationIssue],
106
+ schema: dict[str, Any],
107
+ *,
108
+ attempt: int | None = None,
109
+ max_attempts: int | None = None,
110
+ ) -> dict[str, Any]:
111
+ """Build a repair payload listing every schema violation and column hints."""
112
+ schema_issues = [{"code": issue.code, "message": issue.message} for issue in issues]
113
+ codes = {issue.code for issue in issues}
114
+ if len(codes) == 1:
115
+ code = next(iter(codes))
116
+ else:
117
+ code = "SCHEMA_VALIDATION"
118
+ if len(issues) == 1:
119
+ message = issues[0].message
120
+ elif issues:
121
+ message = f"共 {len(issues)} 项 schema 校验失败:{issues[0].message}"
122
+ else:
123
+ message = "schema 校验失败"
124
+ payload: dict[str, Any] = {
125
+ "code": code,
126
+ "message": message,
127
+ "originalSql": sql,
128
+ "schemaIssues": schema_issues,
129
+ }
130
+ unknown_refs = _unknown_column_refs_from_issues(issues)
131
+ if unknown_refs:
132
+ payload["suggestedColumns"] = suggest_columns_for_unknown(unknown_refs, schema)
133
+ if any(issue.code == "MISSING_TABLE_REFERENCE" for issue in issues):
134
+ payload["tableReferenceRepairHint"] = (
135
+ "SQL 引用了未在 FROM/JOIN 中声明的表名或别名。"
136
+ "如果该字段确实需要参与查询,必须按 schema relations / joins 补 JOIN;"
137
+ "如果不需要该表,则删除对应 SELECT/WHERE/ON/GROUP/ORDER 条件。"
138
+ )
139
+ if any(issue.code == "INVALID_JOIN" for issue in issues):
140
+ payload["joinRepairHint"] = (
141
+ "禁止用 ON 1=0 / ON 1=1 / ON false / ON true 规避 JOIN 校验;"
142
+ "必须按 schemaContext.exampleGuidance、exampleJoinPatterns 与 structureHints 重建真实 JOIN。"
143
+ )
144
+ payload["suggestedJoinPatterns"] = suggest_join_patterns_for_schema(schema)
145
+ if attempt is not None:
146
+ payload["attempt"] = attempt
147
+ if max_attempts is not None:
148
+ payload["maxAttempts"] = max_attempts
149
+ return payload
150
+
151
+
152
+ def build_execute_unknown_column_error(
153
+ sql: str,
154
+ column_ref: str,
155
+ schema: dict[str, Any],
156
+ *,
157
+ attempt: int | None = None,
158
+ max_attempts: int | None = None,
159
+ ) -> dict[str, Any]:
160
+ """Build a repair payload when execute_sql reports a missing column."""
161
+ table_name, column_name = _split_sql_column_ref(column_ref)
162
+ if table_name and column_name:
163
+ message = f"SQL 使用了数据库中不存在的列:{table_name}.{column_name}"
164
+ issue = SchemaValidationIssue(code="UNKNOWN_COLUMN", message=message)
165
+ else:
166
+ message = f"SQL 使用了数据库中不存在的列:{column_ref}"
167
+ issue = SchemaValidationIssue(code="UNKNOWN_COLUMN", message=message)
168
+ return build_schema_validation_error(
169
+ sql,
170
+ [issue],
171
+ schema,
172
+ attempt=attempt,
173
+ max_attempts=max_attempts,
174
+ )
175
+
176
+
177
+ def _split_sql_column_ref(column_ref: str) -> tuple[str, str]:
178
+ cleaned = column_ref.strip().strip("`")
179
+ if "." not in cleaned:
180
+ return "", cleaned
181
+ table_name, column_name = cleaned.rsplit(".", 1)
182
+ return table_name, column_name
183
+
184
+
185
+ def _unknown_column_refs_from_issues(issues: list[SchemaValidationIssue]) -> list[tuple[str, str]]:
186
+ refs: list[tuple[str, str]] = []
187
+ seen: set[tuple[str, str]] = set()
188
+ for issue in issues:
189
+ if issue.code != "UNKNOWN_COLUMN":
190
+ continue
191
+ match = re.search(
192
+ r"不存在的列:([A-Za-z_][\w]*)\.([A-Za-z_][\w]*)",
193
+ issue.message,
194
+ )
195
+ if not match:
196
+ continue
197
+ key = (match.group(1).lower(), match.group(2).lower())
198
+ if key in seen:
199
+ continue
200
+ seen.add(key)
201
+ refs.append(key)
202
+ return refs
203
+
204
+
205
+ def suggest_columns_for_unknown(
206
+ missing: list[tuple[str, str]],
207
+ schema: dict[str, Any],
208
+ ) -> list[dict[str, str]]:
209
+ """Find same column name on other tables when SQL references a non-existent column."""
210
+ column_map = _schema_table_column_map(schema)
211
+ suggestions: list[dict[str, str]] = []
212
+ seen: set[tuple[str, str]] = set()
213
+ for wrong_table, column in missing:
214
+ for table, columns in sorted(column_map.items()):
215
+ if table == wrong_table or column not in columns:
216
+ continue
217
+ key = (table, column)
218
+ if key in seen:
219
+ continue
220
+ seen.add(key)
221
+ suggestions.append(
222
+ {
223
+ "table": table,
224
+ "column": column,
225
+ "message": f"列 {column} 存在于 {table},不存在于 {wrong_table}",
226
+ }
227
+ )
228
+ return suggestions
229
+
230
+
231
+ def build_sql_rule_validation_error(
232
+ sql: str,
233
+ exc: SqlGenerationError,
234
+ *,
235
+ max_limit: int,
236
+ attempt: int | None = None,
237
+ max_attempts: int | None = None,
238
+ ) -> dict[str, Any]:
239
+ """Build a repair payload with the failing SQL and concrete violating SELECT items."""
240
+ message = str(exc)
241
+ payload: dict[str, Any] = {
242
+ "code": "SQL_RULE_VIOLATION",
243
+ "message": message,
244
+ "originalSql": sql,
245
+ "violatingColumns": list_sql_rule_violating_items(sql, message, max_limit=max_limit),
246
+ }
247
+ if attempt is not None:
248
+ payload["attempt"] = attempt
249
+ if max_attempts is not None:
250
+ payload["maxAttempts"] = max_attempts
251
+ return payload
252
+
253
+
254
+ def list_sql_rule_violating_items(sql: str, message: str, *, max_limit: int) -> list[str]:
255
+ lowered = message.lower()
256
+ if "chinese alias" in lowered:
257
+ return _list_missing_chinese_alias_select_items(sql)
258
+ if "null placeholders" in lowered:
259
+ return _list_null_placeholder_select_items(sql)
260
+ if "sensitive field" in lowered:
261
+ return _list_sensitive_select_items(sql)
262
+ if "must be select only" in lowered:
263
+ return ["(entire statement is not a single SELECT)"]
264
+ if "must contain limit" in lowered:
265
+ return ["(missing LIMIT clause)"]
266
+ if "must not exceed" in lowered and "limit" in lowered:
267
+ return ["(LIMIT exceeds allowed maximum)"]
268
+ if "scope placeholders" in lowered:
269
+ return re.findall(r":scope\.\w+", sql, flags=re.IGNORECASE)
270
+ if "like" in lowered:
271
+ return _list_text_filter_violations(sql)
272
+ return []
273
+
274
+
275
+ def _list_missing_chinese_alias_select_items(sql: str) -> list[str]:
276
+ select_list = _top_level_select_list(sql)
277
+ if select_list is None:
278
+ return ["(missing top-level FROM clause)"]
279
+ violations: list[str] = []
280
+ for item in _split_top_level_select_items(select_list):
281
+ cleaned = item.strip()
282
+ if not cleaned:
283
+ continue
284
+ alias_match = re.search(
285
+ _SELECT_AS_ALIAS_PATTERN,
286
+ cleaned,
287
+ flags=re.IGNORECASE,
288
+ )
289
+ if alias_match is None:
290
+ violations.append(cleaned)
291
+ continue
292
+ alias = alias_match.group(1).strip("`\"'")
293
+ if re.search(r"[\u4e00-\u9fff]", alias) is None:
294
+ violations.append(cleaned)
295
+ return violations
296
+
297
+
298
+ def _list_null_placeholder_select_items(sql: str) -> list[str]:
299
+ select_list = _top_level_select_list(sql)
300
+ if select_list is None:
301
+ return []
302
+ return [
303
+ item.strip()
304
+ for item in _split_top_level_select_items(select_list)
305
+ if item.strip() and _is_null_placeholder_expression(_select_expression_without_alias(item))
306
+ ]
307
+
308
+
309
+ def _list_sensitive_select_items(sql: str) -> list[str]:
310
+ select_clause = _top_level_select_clause(sql)
311
+ if select_clause is None:
312
+ return []
313
+ return [item.strip() for item in _split_select_items(select_clause) if _select_item_contains_sensitive_field(item)]
314
+
315
+
316
+ def _list_text_filter_violations(sql: str) -> list[str]:
317
+ return re.findall(
318
+ r"(?i)(?:\b[\w.]+\b)\s*=\s*'[^']*(?:名称|状态|品牌|城市|项目|门店|供应商|工单)[^']*'",
319
+ sql,
320
+ )
321
+
322
+
73
323
  SENSITIVE_FIELD_NAMES = {
74
324
  "phone",
75
325
  "mobile",
@@ -206,268 +456,654 @@ def normalize_text_filters_to_like(sql: str) -> str:
206
456
  return _TEXT_EQUALITY_FILTER_RE.sub(replace, sql)
207
457
 
208
458
 
209
- def _should_normalize_text_filter_to_like(column_name: str, value: str) -> bool:
210
- if not value or _is_numeric_literal(value):
211
- return False
212
- return _is_name_filter_column(column_name) or _is_status_text_filter_column(column_name)
213
-
459
+ def normalize_chinese_select_aliases(sql: str, schema: dict[str, Any] | None) -> str:
460
+ """Add or replace SELECT aliases with Chinese labels from schema column comments."""
461
+ if not schema or not sql.strip():
462
+ return sql
463
+ select_list = _top_level_select_list(sql)
464
+ if select_list is None:
465
+ return sql
214
466
 
215
- def _enforce_text_filters_use_like(sql: str) -> None:
216
- for match in _TEXT_EQUALITY_FILTER_RE.finditer(sql):
217
- column = match.group("column").rsplit(".", 1)[-1].lower()
218
- value = match.group("value").strip()
219
- if _should_normalize_text_filter_to_like(column, value):
220
- raise SqlGenerationError("SQL text filters must use LIKE")
467
+ table_aliases = _sql_table_aliases(sql)
468
+ used_labels: set[str] = set()
469
+ new_items: list[str] = []
470
+ changed = False
471
+ for item in _split_top_level_select_items(select_list):
472
+ cleaned = item.strip()
473
+ if not cleaned:
474
+ new_items.append(cleaned)
475
+ continue
476
+ if _select_item_has_chinese_alias(cleaned):
477
+ alias = _extract_select_alias(cleaned)
478
+ if alias:
479
+ used_labels.add(alias)
480
+ new_items.append(cleaned)
481
+ continue
482
+ expr = _select_expression_without_alias(cleaned)
483
+ label = _infer_chinese_select_alias(expr, table_aliases=table_aliases, schema=schema)
484
+ if label is None or re.search(r"[\u4e00-\u9fff]", label) is None:
485
+ new_items.append(cleaned)
486
+ continue
487
+ label = _dedupe_chinese_alias(label, used_labels)
488
+ used_labels.add(label)
489
+ new_items.append(f"{expr} AS {label}")
490
+ changed = True
491
+ if not changed:
492
+ return sql
493
+ return _replace_top_level_select_list(sql, ", ".join(new_items))
221
494
 
222
495
 
223
- def _is_name_filter_column(column: str) -> bool:
224
- return column == "name" or column == "job_name" or column.endswith("_name")
496
+ def question_requests_id_field(question: str) -> bool:
497
+ lowered = question.lower()
498
+ return "id" in lowered or "编号" in question or "主键" in question
225
499
 
226
500
 
227
- def _is_status_text_filter_column(column: str) -> bool:
228
- return column in {"status", "state"} or column.endswith("_status") or column.endswith("_state")
501
+ def strip_identifier_columns_from_select(sql: str, question: str) -> str:
502
+ """Drop bare id / *_id columns from SELECT unless the question explicitly asks for ids."""
503
+ if question_requests_id_field(question):
504
+ return sql
505
+ select_list = _top_level_select_list(sql)
506
+ if select_list is None:
507
+ return sql
508
+ table_aliases = _sql_table_aliases(sql)
509
+ kept: list[str] = []
510
+ for item in _split_top_level_select_items(select_list):
511
+ cleaned = item.strip()
512
+ if not cleaned:
513
+ continue
514
+ expr = _select_expression_without_alias(cleaned)
515
+ if _select_expr_is_identifier_column(expr, table_aliases):
516
+ continue
517
+ kept.append(cleaned)
518
+ if not kept or len(kept) == len(_split_top_level_select_items(select_list)):
519
+ return sql
520
+ return _replace_top_level_select_list(sql, ", ".join(kept))
229
521
 
230
522
 
231
- def _is_numeric_literal(value: str) -> bool:
232
- return bool(re.fullmatch(r"-?\d+(?:\.\d+)?", value))
523
+ def apply_enum_case_to_select(sql: str, schema: dict[str, Any] | None) -> str:
524
+ """Wrap enum-backed scalar columns in CASE WHEN so SELECT shows Chinese labels."""
525
+ if not schema or not sql.strip():
526
+ return sql
527
+ select_list = _top_level_select_list(sql)
528
+ if select_list is None:
529
+ return sql
530
+ enums_by_field = {
531
+ str(entry.get("field") or ""): entry.get("values")
532
+ for entry in merged_schema_enums(schema)
533
+ if isinstance(entry, dict) and entry.get("field")
534
+ }
535
+ table_aliases = _sql_table_aliases(sql)
536
+ used_labels: set[str] = set()
537
+ new_items: list[str] = []
538
+ changed = False
539
+ for item in _split_top_level_select_items(select_list):
540
+ cleaned = item.strip()
541
+ if not cleaned:
542
+ new_items.append(cleaned)
543
+ continue
544
+ if re.match(r"case\s+", _select_expression_without_alias(cleaned), flags=re.IGNORECASE):
545
+ alias = _extract_select_alias(cleaned)
546
+ if alias:
547
+ used_labels.add(alias)
548
+ new_items.append(cleaned)
549
+ continue
550
+ expr = _select_expression_without_alias(cleaned)
551
+ case_expr = _enum_case_expression_for_select_item(
552
+ expr,
553
+ table_aliases=table_aliases,
554
+ enums_by_field=enums_by_field,
555
+ schema=schema,
556
+ used_labels=used_labels,
557
+ existing_alias=_extract_select_alias(cleaned),
558
+ )
559
+ if case_expr is None:
560
+ alias = _extract_select_alias(cleaned)
561
+ if alias:
562
+ used_labels.add(alias)
563
+ new_items.append(cleaned)
564
+ continue
565
+ used_labels.add(_extract_select_alias(case_expr) or "")
566
+ new_items.append(case_expr)
567
+ changed = True
568
+ if not changed:
569
+ return sql
570
+ return _replace_top_level_select_list(sql, ", ".join(new_items))
233
571
 
234
572
 
235
- def is_low_quality_sql_for_question(question: str, sql: str, *, schema: dict[str, Any] | None = None) -> bool:
236
- return low_quality_sql_issue_for_question(question, sql, schema=schema) is not None
573
+ def suggest_join_patterns_for_schema(schema: dict[str, Any]) -> list[dict[str, Any]]:
574
+ suggestions: list[dict[str, Any]] = []
575
+ for example in _schema_examples(schema):
576
+ if not isinstance(example, dict):
577
+ continue
578
+ join_pattern = example.get("joinPattern")
579
+ if not isinstance(join_pattern, dict):
580
+ continue
581
+ item: dict[str, Any] = {"joinPattern": join_pattern}
582
+ for key in ("id", "intent", "notes", "questionExamples"):
583
+ value = example.get(key)
584
+ if value:
585
+ item[key] = value
586
+ suggestions.append(item)
587
+ return suggestions[:6]
237
588
 
238
589
 
239
- def low_quality_sql_issue_for_question(question: str, sql: str, *, schema: dict[str, Any] | None = None) -> LowQualitySqlIssue | None:
240
- normalized = re.sub(r"\s+", " ", sql.strip().lower())
241
- if _is_business_query(question) and _is_single_entity_lookup_sql(sql):
242
- return LowQualitySqlIssue(
243
- code="BUSINESS_QUERY_SINGLE_ENTITY_LOOKUP",
244
- message="本次查询未执行:这是业务数据查询,但生成的 SQL 退化成了品牌、项目、城市、门店或岗位的单表列表,不能回答当前问题。",
245
- )
246
- if _job_status_filter_mismatches_question(question, sql, schema):
247
- return LowQualitySqlIssue(
248
- code="JOB_STATUS_ENUM_MISMATCH",
249
- message="本次查询未执行:你问的是岗位状态相关数据,但生成的 SQL 没有使用 schema 枚举中对应的岗位状态值,可能把未发布、已发布、已下架等状态混在一起。",
250
- )
251
- if _plain_job_total_question_has_unrequested_status_filter(question, sql):
252
- return LowQualitySqlIssue(
253
- code="JOB_TOTAL_SHOULD_NOT_FILTER_STATUS",
254
- message="本次查询未执行:你问的是岗位总数,但生成的 SQL 加了岗位状态过滤,会把全部岗位总数误算成特定状态的岗位数量。",
255
- )
256
- if _generic_job_count_sql_mismatches_question(question, sql, schema):
257
- return LowQualitySqlIssue(
258
- code="JOB_COUNT_ALIAS_MISMATCH",
259
- message="本次查询未执行:你问的是岗位总数,但生成的 SQL 输出字段是“在招岗位数/已发布岗位数”等状态口径,指标名称和查询口径不一致。",
260
- )
261
- brand_filter = _extract_brand_filter(question)
262
- if brand_filter is not None and _sql_references_brand_table(sql) and not _sql_contains_brand_name_filter(sql, brand_filter[1]):
263
- return LowQualitySqlIssue(
264
- code="MISSING_BRAND_FILTER",
265
- message=f"本次查询未执行:你明确指定了品牌“{brand_filter[1]}”,但生成的 SQL 没有按该品牌名称过滤。",
266
- )
267
- project_name = _extract_project_name(question)
268
- if (
269
- project_name is not None
270
- and _sql_references_table(sql, "sponge_project")
271
- and not _sql_contains_name_filter(sql, project_name)
272
- and not _sql_contains_project_id_filter(sql)
273
- ):
274
- return LowQualitySqlIssue(
275
- code="MISSING_PROJECT_FILTER",
276
- message=f"本次查询未执行:你明确指定了项目“{project_name}”,但生成的 SQL 没有按该项目名称过滤。",
277
- )
278
- job_name = _extract_job_name_filter(question)
279
- if job_name is not None and _sql_references_table(sql, "job_basic_info") and not _sql_contains_job_name_filter(sql, job_name):
280
- return LowQualitySqlIssue(
281
- code="MISSING_JOB_NAME_FILTER",
282
- message=f"本次查询未执行:你明确指定了岗位“{job_name}”,但生成的 SQL 没有按该岗位名称过滤。",
283
- )
284
- store_name = _extract_store_name_filter(question)
285
- if store_name is not None and _sql_references_table(sql, "store") and not _sql_contains_store_name_filter(sql, store_name):
286
- return LowQualitySqlIssue(
287
- code="MISSING_STORE_FILTER",
288
- message=f"本次查询未执行:你明确指定了门店“{store_name}”,但生成的 SQL 没有按该门店名称过滤。",
289
- )
290
- if _is_work_order_detail_query(question):
291
- if "mvp_applied_jobs_by_helped_account" not in normalized:
292
- return LowQualitySqlIssue(
293
- code="MISSING_WORK_ORDER_TABLE",
294
- message="本次查询未执行:你问的是报名/工单明细,但生成的 SQL 没有使用报名工单表。",
295
- )
296
- if re.search(r"\bfrom\s+brand\b", normalized) and "mvp_applied_jobs_by_helped_account" not in normalized:
297
- return LowQualitySqlIssue(
298
- code="WORK_ORDER_QUERY_BRAND_ONLY",
299
- message="本次查询未执行:你问的是报名/工单明细,但生成的 SQL 只查询了品牌列表。",
300
- )
301
- if _has_work_order_job_id_to_job_pk_join(normalized):
302
- return LowQualitySqlIssue(
303
- code="WORK_ORDER_JOB_JOIN_MISMATCH",
304
- message="本次查询未执行:报名工单和岗位表的关联方式不符合当前 schema 声明。",
305
- )
306
- if "面试成功" in question and "interview_pass_status" in normalized:
307
- return LowQualitySqlIssue(
308
- code="INTERVIEW_STATUS_FIELD_MISMATCH",
309
- message="本次查询未执行:你问的是候选人面试成功状态,但生成的 SQL 使用了不符合当前口径的面试状态字段。",
310
- )
311
- return None
590
+ def finalize_generated_sql(
591
+ sql: str,
592
+ question: str,
593
+ *,
594
+ schema: dict[str, Any] | None = None,
595
+ ) -> str:
596
+ normalized = normalize_text_filters_to_like(sql)
597
+ normalized = strip_identifier_columns_from_select(normalized, question)
598
+ normalized = apply_enum_case_to_select(normalized, schema)
599
+ normalized = normalize_chinese_select_aliases(normalized, schema)
600
+ return normalized
312
601
 
313
602
 
314
- def _job_status_filter_mismatches_question(question: str, sql: str, schema: dict[str, Any] | None) -> bool:
315
- requested_values = _requested_job_status_values(question, schema)
316
- if requested_values is None:
317
- return False
318
- table_aliases = _sql_table_aliases(sql)
319
- job_aliases = {alias for alias, table in table_aliases.items() if table == "job_basic_info"}
320
- if not job_aliases:
321
- return False
322
- actual_values = _sql_status_filter_values(sql, job_aliases)
323
- if actual_values is None:
603
+ def _select_expr_is_identifier_column(expr: str, table_aliases: dict[str, str]) -> bool:
604
+ normalized = expr.strip()
605
+ match = re.fullmatch(r"([a-zA-Z_][\w]*)\.([a-zA-Z_][\w]*)", normalized)
606
+ if match is None:
607
+ return normalized.lower() in {"id"} or normalized.lower().endswith("_id")
608
+ alias, column = match.group(1).lower(), match.group(2).lower()
609
+ if column == "id" or column.endswith("_id"):
324
610
  return True
325
- return actual_values != requested_values
611
+ table = table_aliases.get(alias)
612
+ if table and (column == "id" or column.endswith("_id")):
613
+ return True
614
+ return False
326
615
 
327
616
 
328
- def _plain_job_total_question_has_unrequested_status_filter(question: str, sql: str) -> bool:
329
- if not _is_job_count_intent_question(question):
330
- return False
331
- if _user_requests_recruiting_job_count(question):
332
- return False
333
- if re.search(r"(未发布|已下架|状态包括|状态为|岗位状态)", question):
334
- return False
335
- table_aliases = _sql_table_aliases(sql)
336
- job_aliases = {alias for alias, table in table_aliases.items() if table == "job_basic_info"}
337
- if not job_aliases:
338
- return False
339
- return _sql_status_filter_values(sql, job_aliases) is not None
617
+ def _enum_case_expression_for_select_item(
618
+ expr: str,
619
+ *,
620
+ table_aliases: dict[str, str],
621
+ enums_by_field: dict[str, Any],
622
+ schema: dict[str, Any],
623
+ used_labels: set[str],
624
+ existing_alias: str | None,
625
+ ) -> str | None:
626
+ match = re.fullmatch(r"([a-zA-Z_][\w]*)\.([a-zA-Z_][\w]*)", expr.strip())
627
+ if match is None:
628
+ return None
629
+ alias, column = match.group(1), match.group(2)
630
+ table = table_aliases.get(alias.lower())
631
+ if table is None:
632
+ return None
633
+ values = enums_by_field.get(f"{table}.{column}")
634
+ if not isinstance(values, list) or not values:
635
+ return None
636
+ when_parts: list[str] = []
637
+ for item in values:
638
+ if not isinstance(item, dict):
639
+ continue
640
+ value = item.get("value")
641
+ label = str(item.get("label") or "").strip()
642
+ if value is None or not label:
643
+ continue
644
+ literal = repr(str(value)) if isinstance(value, str) else str(value)
645
+ when_parts.append(f"WHEN {expr} = {literal} THEN {_sql_string_literal(label)}")
646
+ if not when_parts:
647
+ return None
648
+ alias_label = existing_alias or _chinese_label_for_column(schema, table, column) or column
649
+ alias_label = _dedupe_chinese_alias(alias_label, used_labels)
650
+ return f"CASE {' '.join(when_parts)} ELSE CAST({expr} AS CHAR) END AS {alias_label}"
340
651
 
341
652
 
342
- def _is_job_count_intent_question(question: str) -> bool:
653
+ def _sql_string_literal(value: str) -> str:
654
+ return "'" + value.replace("'", "''") + "'"
655
+
656
+
657
+ def _enforce_no_tautological_join_conditions(sql: str) -> None:
343
658
  if re.search(
344
- r"(岗位|职位).{0,8}(总数|数量|数)|(?:总数|数量|多少|几个).{0,8}(岗位|职位)",
345
- question,
659
+ r"\b(?:left|right|inner|full|cross)?\s*join\b[\s\S]*?\bon\s+(?:1\s*=\s*0|0\s*=\s*1|1\s*=\s*1|true|false)\b",
660
+ sql,
661
+ flags=re.IGNORECASE,
346
662
  ):
347
- return True
348
- if re.search(r"(?:查|查询|统计|看).{0,6}总数", question):
349
- return True
350
- return bool(re.fullmatch(r"(?:查询|统计)?总数", question.strip()))
663
+ raise SqlGenerationError(
664
+ "SQL JOIN must not use tautological ON conditions such as ON 1=0 or ON 1=1; "
665
+ "use schema exampleJoinPatterns and structureHints to build real joins"
666
+ )
351
667
 
352
668
 
353
- def _user_requests_recruiting_job_count(question: str, schema: dict[str, Any] | None = None) -> bool:
354
- if re.search(r"在招岗位数|在招|已发布|已上架", question):
355
- return True
356
- requested_values = _requested_job_status_values(question, schema)
357
- if requested_values is None:
358
- return False
359
- if schema is None:
360
- return True
361
- status_enum = _status_enum(schema, "job_basic_info.status")
362
- if not status_enum:
363
- return True
364
- recruiting_values: set[str] = set()
365
- for item in status_enum:
366
- value = item.get("value")
367
- labels = _enum_label_terms(str(item.get("label") or ""))
368
- if value is None:
669
+ _JOIN_ON_START_RE = re.compile(
670
+ r"\b(?:(?:left|right|inner|full|cross)\s+)?join\s+(?:\([^)]+\)|[a-zA-Z_][\w]*(?:\s+(?:as\s+)?[a-zA-Z_][\w]*)?)\s+on\s+",
671
+ re.IGNORECASE,
672
+ )
673
+ _JOIN_ON_END_RE = re.compile(
674
+ r"\b(where|group\s+by|order\s+by|limit|(?:left|right|inner|full|cross)\s+join)\b",
675
+ re.IGNORECASE,
676
+ )
677
+ _CROSS_TABLE_EQUALITY_RE = re.compile(
678
+ r"(?:"
679
+ r"\b[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*"
680
+ r"(?:cast\s*\(\s*[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*\s+as\s+\w+\s*\)"
681
+ r"|[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*)"
682
+ r"|"
683
+ r"cast\s*\(\s*[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*\s+as\s+\w+\s*\)\s*=\s*"
684
+ r"[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*)",
685
+ re.IGNORECASE,
686
+ )
687
+
688
+
689
+ def _iter_join_on_clauses(sql: str) -> list[str]:
690
+ clauses: list[str] = []
691
+ for match in _JOIN_ON_START_RE.finditer(sql):
692
+ rest = sql[match.end() :]
693
+ end_match = _JOIN_ON_END_RE.search(rest)
694
+ end = end_match.start() if end_match else len(rest)
695
+ clause = rest[:end].strip()
696
+ if clause:
697
+ clauses.append(clause)
698
+ return clauses
699
+
700
+
701
+ def _list_join_on_violations(sql: str) -> list[str]:
702
+ violations: list[str] = []
703
+ for on_clause in _iter_join_on_clauses(sql):
704
+ preview = re.sub(r"\s+", " ", on_clause).strip()[:120]
705
+ if re.search(r"\blike\b", on_clause, re.IGNORECASE):
706
+ violations.append(
707
+ "JOIN ON 禁止写 LIKE 筛选,请按 schema relations 写主表与关联表的等值键,"
708
+ f"把实体筛选放到 WHERE:{preview}"
709
+ )
710
+ continue
711
+ if re.search(r"\bin\s*\(", on_clause, re.IGNORECASE):
712
+ violations.append(
713
+ "JOIN ON 禁止写 IN 筛选,请按 schema relations 写主表与关联表的等值键,"
714
+ f"把实体筛选放到 WHERE:{preview}"
715
+ )
369
716
  continue
370
- if any(label in {"在招", "已发布", "已上架"} or "在招" in label for label in labels):
371
- recruiting_values.add(str(value))
372
- return bool(requested_values & recruiting_values)
717
+ if _CROSS_TABLE_EQUALITY_RE.search(on_clause) is None:
718
+ violations.append(
719
+ "JOIN ON 必须使用主表与关联表的等值关联(如 store.organization_id = sponge_project.id),"
720
+ f"不能只有筛选条件:{preview}"
721
+ )
722
+ return violations
373
723
 
374
724
 
375
- def _preferred_count_alias(question: str) -> str | None:
376
- if not _is_job_count_intent_question(question):
377
- return None
378
- if "在招岗位数" in question:
379
- return "在招岗位数"
380
- if re.search(r"岗位总数", question):
381
- return "岗位总数"
382
- if re.search(r"职位总数", question):
383
- return "职位总数"
384
- if re.search(r"岗位数", question):
385
- return "岗位数"
386
- if re.search(r"职位数", question):
387
- return "职位数"
388
- if "总数" in question:
389
- return "总数"
390
- if re.search(r"(岗位|职位).{0,8}(总数|数量|数)|(?:总数|数量|多少|几个).{0,8}(岗位|职位)", question):
391
- return "岗位总数"
392
- return None
725
+ def _enforce_join_on_uses_equality_keys(sql: str) -> None:
726
+ violations = _list_join_on_violations(sql)
727
+ if violations:
728
+ raise SqlGenerationError(violations[0])
393
729
 
394
730
 
395
- def _sql_select_metric_aliases(sql: str) -> list[str]:
396
- select_list = _top_level_select_list(sql)
397
- if select_list is None:
398
- return []
399
- aliases: list[str] = []
400
- for item in _split_top_level_select_items(select_list):
401
- alias_match = re.search(
402
- r"\s+as\s+(`[^`]+`|\"[^\"]+\"|'[^']+'|[\u4e00-\u9fff][\w\u4e00-\u9fff]*)\s*$",
403
- item.strip(),
404
- flags=re.IGNORECASE,
731
+ _AGGREGATE_FUNCTION_RE = re.compile(
732
+ r"\b(?:count|sum|avg|min|max|group_concat)\s*\(",
733
+ flags=re.IGNORECASE,
734
+ )
735
+
736
+
737
+ def _enforce_no_aggregate_functions_in_row_filters(sql: str) -> None:
738
+ for clause in _iter_join_on_clauses(sql):
739
+ if _AGGREGATE_FUNCTION_RE.search(clause):
740
+ raise SqlGenerationError(
741
+ "SQL aggregate functions must not be used in JOIN ON; "
742
+ "aggregate first in a subquery or move aggregate predicates to HAVING"
743
+ )
744
+ where_clause = _top_level_where_clause(sql)
745
+ if where_clause and _AGGREGATE_FUNCTION_RE.search(where_clause):
746
+ raise SqlGenerationError(
747
+ "SQL aggregate functions must not be used in WHERE; "
748
+ "move aggregate predicates to HAVING or aggregate first in a subquery"
405
749
  )
406
- if alias_match is not None:
407
- aliases.append(alias_match.group(1).strip("`\"'"))
408
- return aliases
409
750
 
410
751
 
411
- def _generic_job_count_sql_mismatches_question(
412
- question: str,
413
- sql: str,
414
- schema: dict[str, Any] | None,
415
- ) -> bool:
416
- if not _is_job_count_intent_question(question) or not _sql_counts_job_basic_info(sql):
417
- return False
418
- preferred_alias = _preferred_count_alias(question)
419
- aliases = _sql_select_metric_aliases(sql)
420
- if preferred_alias and aliases and preferred_alias not in aliases:
421
- if any(alias in {"在招岗位数", "在招岗位", "已发布岗位数"} for alias in aliases):
422
- return not _user_requests_recruiting_job_count(question, schema)
423
- if preferred_alias == "总数" and any("在招" in alias for alias in aliases):
424
- return not _user_requests_recruiting_job_count(question, schema)
425
- if _user_requests_recruiting_job_count(question, schema):
426
- return False
427
- if any("在招" in alias for alias in aliases):
428
- return True
429
- return False
752
+ def _top_level_where_clause(sql: str) -> str:
753
+ where_index = _find_top_level_keyword(sql, "where", start=0)
754
+ if where_index is None:
755
+ return ""
756
+ body_start = where_index + len("where")
757
+ suffix_starts = [
758
+ index
759
+ for keyword in ("group by", "having", "order by", "limit")
760
+ if (index := _find_top_level_keyword(sql, keyword, start=body_start)) is not None
761
+ ]
762
+ body_end = min(suffix_starts) if suffix_starts else len(sql)
763
+ return sql[body_start:body_end]
430
764
 
431
765
 
432
- def _sql_counts_job_basic_info(sql: str) -> bool:
433
- normalized = re.sub(r"\s+", " ", sql.strip().lower())
434
- return "count(" in normalized and "job_basic_info" in normalized
766
+ def _select_item_has_chinese_alias(item: str) -> bool:
767
+ alias = _extract_select_alias(item)
768
+ return alias is not None and re.search(r"[\u4e00-\u9fff]", alias) is not None
435
769
 
436
770
 
437
- def _strip_job_status_filters(sql: str) -> str:
438
- table_aliases = _sql_table_aliases(sql)
439
- job_aliases = {alias for alias, table in table_aliases.items() if table == "job_basic_info"}
440
- if not job_aliases:
441
- return sql
442
- alias_pattern = "|".join(re.escape(alias) for alias in sorted(job_aliases, key=len, reverse=True))
443
- columns = [r"(?<!\.)\bstatus"]
444
- if alias_pattern:
445
- columns.append(rf"\b(?:{alias_pattern})\.status")
446
- cleaned = sql
447
- for column in columns:
448
- cleaned = re.sub(
449
- rf"\s+and\s+{column}\s*=\s*-?\d+\b",
450
- "",
451
- cleaned,
452
- flags=re.IGNORECASE,
453
- )
454
- cleaned = re.sub(
455
- rf"\s+and\s+{column}\s+in\s*\([^)]*\)",
456
- "",
457
- cleaned,
458
- flags=re.IGNORECASE,
459
- )
460
- return cleaned
771
+ def _extract_select_alias(item: str) -> str | None:
772
+ alias_match = re.search(
773
+ _SELECT_AS_ALIAS_PATTERN,
774
+ item.strip(),
775
+ flags=re.IGNORECASE,
776
+ )
777
+ if alias_match is None:
778
+ return None
779
+ return alias_match.group(1).strip("`\"'")
461
780
 
462
781
 
463
- def _replace_primary_metric_alias(sql: str, alias: str) -> str:
464
- select_list = _top_level_select_list(sql)
465
- if select_list is None:
466
- return sql
467
- items = _split_top_level_select_items(select_list)
468
- if not items:
469
- return sql
470
- last_item = items[-1].strip()
782
+ def _dedupe_chinese_alias(label: str, used: set[str]) -> str:
783
+ if label not in used:
784
+ return label
785
+ index = 2
786
+ while f"{label}{index}" in used:
787
+ index += 1
788
+ return f"{label}{index}"
789
+
790
+
791
+ def _infer_chinese_select_alias(
792
+ expr: str,
793
+ *,
794
+ table_aliases: dict[str, str],
795
+ schema: dict[str, Any],
796
+ ) -> str | None:
797
+ normalized = expr.strip()
798
+ if not normalized:
799
+ return None
800
+
801
+ aggregate_match = re.match(r"(count|sum|avg|min|max)\s*\(", normalized, flags=re.IGNORECASE)
802
+ if aggregate_match is not None:
803
+ labels = {
804
+ "count": "数量",
805
+ "sum": "合计",
806
+ "avg": "平均值",
807
+ "min": "最小值",
808
+ "max": "最大值",
809
+ }
810
+ return labels.get(aggregate_match.group(1).lower())
811
+
812
+ if re.match(r"case\s+", normalized, flags=re.IGNORECASE):
813
+ return "结果"
814
+
815
+ qualified_match = re.search(
816
+ r"(?:([a-zA-Z_][a-zA-Z0-9_]*)\.)?([a-zA-Z_][a-zA-Z0-9_]*)\s*$",
817
+ normalized,
818
+ )
819
+ if qualified_match is not None:
820
+ alias_part, column_name = qualified_match.group(1), qualified_match.group(2)
821
+ if alias_part is not None:
822
+ table_name = table_aliases.get(alias_part.lower())
823
+ if table_name is not None:
824
+ return _chinese_label_for_column(schema, table_name, column_name)
825
+ return _chinese_label_for_bare_column(schema, table_aliases, column_name)
826
+ return None
827
+
828
+
829
+ def _chinese_label_for_column(schema: dict[str, Any], table_name: str, column_name: str) -> str | None:
830
+ for column in _table_columns(schema, table_name):
831
+ name = str(column.get("columnName") or column.get("name") or "")
832
+ if name.lower() != column_name.lower():
833
+ continue
834
+ label = _column_alias(name, str(column.get("comment") or ""))
835
+ if re.search(r"[\u4e00-\u9fff]", label):
836
+ return label
837
+ return None
838
+
839
+
840
+ def _chinese_label_for_bare_column(
841
+ schema: dict[str, Any],
842
+ table_aliases: dict[str, str],
843
+ column_name: str,
844
+ ) -> str | None:
845
+ labels = [
846
+ label
847
+ for table_name in set(table_aliases.values())
848
+ if (label := _chinese_label_for_column(schema, table_name, column_name)) is not None
849
+ ]
850
+ if len(labels) == 1:
851
+ return labels[0]
852
+ return None
853
+
854
+
855
+ def _replace_top_level_select_list(sql: str, select_list: str) -> str:
856
+ stripped = sql.strip().rstrip(";")
857
+ match = re.match(r"select\s+(?:distinct\s+)?", stripped, flags=re.IGNORECASE)
858
+ if match is None:
859
+ return sql
860
+ start = match.end()
861
+ from_index = _find_top_level_keyword(stripped, "from", start=start)
862
+ if from_index is None:
863
+ return sql
864
+ prefix = stripped[:start].rstrip()
865
+ suffix = stripped[from_index:].lstrip()
866
+ return f"{prefix} {select_list} {suffix}"
867
+
868
+
869
+ def _should_normalize_text_filter_to_like(column_name: str, value: str) -> bool:
870
+ if not value or _is_numeric_literal(value):
871
+ return False
872
+ return _is_name_filter_column(column_name) or _is_status_text_filter_column(column_name)
873
+
874
+
875
+ def _enforce_text_filters_use_like(sql: str) -> None:
876
+ for match in _TEXT_EQUALITY_FILTER_RE.finditer(sql):
877
+ column = match.group("column").rsplit(".", 1)[-1].lower()
878
+ value = match.group("value").strip()
879
+ if _should_normalize_text_filter_to_like(column, value):
880
+ raise SqlGenerationError("SQL text filters must use LIKE")
881
+
882
+
883
+ def _is_name_filter_column(column: str) -> bool:
884
+ return column == "name" or column == "job_name" or column.endswith("_name")
885
+
886
+
887
+ def _is_status_text_filter_column(column: str) -> bool:
888
+ return column in {"status", "state"} or column.endswith("_status") or column.endswith("_state")
889
+
890
+
891
+ def _is_numeric_literal(value: str) -> bool:
892
+ return bool(re.fullmatch(r"-?\d+(?:\.\d+)?", value))
893
+
894
+
895
+ def is_low_quality_sql_for_question(question: str, sql: str, *, schema: dict[str, Any] | None = None) -> bool:
896
+ return low_quality_sql_issue_for_question(question, sql, schema=schema) is not None
897
+
898
+
899
+ def low_quality_sql_issue_for_question(question: str, sql: str, *, schema: dict[str, Any] | None = None) -> LowQualitySqlIssue | None:
900
+ if schema is None:
901
+ return None
902
+ from .schema_context_retriever import match_concepts_for_question
903
+
904
+ matched_concepts = match_concepts_for_question(_schema_concepts(schema), question)
905
+ normalized = sql.strip().lower()
906
+ for concept in matched_concepts:
907
+ checks = concept.get("sqlQualityChecks")
908
+ if not _concept_quality_check_applies(concept, question):
909
+ continue
910
+ time_range_issue = _concept_time_range_field_issue(question, sql, concept)
911
+ if time_range_issue is not None:
912
+ return time_range_issue
913
+ if not isinstance(checks, dict):
914
+ continue
915
+ for table_name in checks.get("requireTables") or []:
916
+ table = str(table_name or "").strip().lower()
917
+ if table and table not in normalized:
918
+ message = str(checks.get("message") or f"SQL 必须引用 schema 要求的表:{table_name}")
919
+ concept_label = str(concept.get("comment") or concept.get("name") or "命中的 schema concept")
920
+ return LowQualitySqlIssue(
921
+ code="SCHEMA_SQL_QUALITY",
922
+ message=message,
923
+ missing_items=(f"必须引用表 {table_name}",),
924
+ current_problem=(
925
+ f"当前 SQL 没有使用 {table_name},无法覆盖“{concept_label}”要求的查询口径。"
926
+ ),
927
+ repair_hint=(
928
+ f"按 schema.concepts 中“{concept_label}”的定义补充 {table_name} 及对应 JOIN/WHERE 条件。"
929
+ ),
930
+ )
931
+ aggregate_issue = _aggregate_sql_quality_issue(question, sql)
932
+ if aggregate_issue is not None:
933
+ return aggregate_issue
934
+ return None
935
+
936
+
937
+ def _concept_time_range_field_issue(
938
+ question: str,
939
+ sql: str,
940
+ concept: dict[str, Any],
941
+ ) -> LowQualitySqlIssue | None:
942
+ qualified = str(concept.get("timeRangeField") or "").strip()
943
+ if "." not in qualified or _extract_time_bounds(question) is None:
944
+ return None
945
+ table_name, field_name = qualified.rsplit(".", 1)
946
+ time_range = TimeRange(field=field_name, alias="时间范围", start="", end="", table_name=table_name)
947
+ if not _sql_has_time_filter_for_range(sql, time_range):
948
+ return LowQualitySqlIssue(
949
+ code="SCHEMA_TIME_RANGE_FIELD_REQUIRED",
950
+ message=(
951
+ f"用户问题包含时间范围,且 schema concept 指定时间字段为 {qualified};"
952
+ "SQL 必须使用该字段过滤时间范围,不要改用关联维表的同名时间字段。"
953
+ ),
954
+ missing_items=(f"时间范围字段 {qualified}",),
955
+ current_problem=f"当前 SQL 没有使用 {qualified} 过滤用户要求的时间范围。",
956
+ repair_hint=f"在 WHERE 中使用 {qualified} 补充时间范围过滤。",
957
+ )
958
+ unexpected = _unexpected_time_filter_fields(sql, expected_field=qualified)
959
+ if unexpected:
960
+ return LowQualitySqlIssue(
961
+ code="SCHEMA_TIME_RANGE_FIELD_REQUIRED",
962
+ message=(
963
+ f"用户问题包含时间范围,且 schema concept 指定时间字段为 {qualified};"
964
+ f"SQL 不应同时使用 {'、'.join(unexpected)} 过滤时间范围。"
965
+ ),
966
+ missing_items=(f"唯一时间范围字段 {qualified}",),
967
+ current_problem=f"当前 SQL 还使用了 {'、'.join(unexpected)} 过滤时间范围,可能把口径切到关联表时间。",
968
+ repair_hint=f"删除关联表时间过滤,只保留 {qualified} 的时间范围条件。",
969
+ )
970
+ return None
971
+
972
+
973
+ def _sql_has_time_filter_for_range(sql: str, time_range: TimeRange) -> bool:
974
+ where_index = _find_top_level_keyword(sql, "where", start=0)
975
+ if where_index is None:
976
+ return False
977
+ clause_start = where_index + len("where")
978
+ clause_end = _where_filter_insert_pos(sql[clause_start:])
979
+ if clause_end < len(sql[clause_start:]):
980
+ clause_end += clause_start
981
+ else:
982
+ clause_end = len(sql)
983
+ for predicate in _split_top_level_and_predicates(sql[clause_start:clause_end].strip()):
984
+ if _is_time_range_predicate(predicate, sql=sql, time_range=time_range):
985
+ return True
986
+ return False
987
+
988
+
989
+ def _unexpected_time_filter_fields(sql: str, *, expected_field: str) -> list[str]:
990
+ expected_table, expected_column = expected_field.lower().rsplit(".", 1)
991
+ aliases = _sql_table_aliases(sql)
992
+ unexpected: list[str] = []
993
+ seen: set[str] = set()
994
+ where_index = _find_top_level_keyword(sql, "where", start=0)
995
+ if where_index is None:
996
+ return []
997
+ clause_start = where_index + len("where")
998
+ clause_end = _where_filter_insert_pos(sql[clause_start:])
999
+ if clause_end < len(sql[clause_start:]):
1000
+ clause_end += clause_start
1001
+ else:
1002
+ clause_end = len(sql)
1003
+ for predicate in _split_top_level_and_predicates(sql[clause_start:clause_end].strip()):
1004
+ if re.search(r"'20\d{2}-\d{1,2}-\d{1,2}", predicate) is None:
1005
+ continue
1006
+ if re.search(r"(?:\bbetween\b|>=|>|<=|<|=)", predicate, flags=re.IGNORECASE) is None:
1007
+ continue
1008
+ for match in re.finditer(r"\b([a-zA-Z_][\w]*)\.([a-zA-Z_][\w]*)\b", predicate):
1009
+ qualifier = match.group(1).lower()
1010
+ column = match.group(2).lower()
1011
+ if column != expected_column:
1012
+ continue
1013
+ table = aliases.get(qualifier, qualifier)
1014
+ qualified = f"{table}.{column}"
1015
+ if qualified == f"{expected_table}.{expected_column}" or qualified in seen:
1016
+ continue
1017
+ seen.add(qualified)
1018
+ unexpected.append(qualified)
1019
+ return unexpected
1020
+
1021
+
1022
+ def _aggregate_sql_quality_issue(question: str, sql: str) -> LowQualitySqlIssue | None:
1023
+ if not _question_requires_aggregate_sql(question):
1024
+ return None
1025
+ normalized = sql.lower()
1026
+ if re.search(r"\b(count|sum|avg|min|max)\s*\(", normalized):
1027
+ return None
1028
+ return LowQualitySqlIssue(
1029
+ code="AGGREGATE_SQL_REQUIRED",
1030
+ message="用户问题要求统计数量/最多值及对应维度,SQL 必须使用 COUNT/SUM/AVG/MIN/MAX 等聚合函数,不能只返回明细行。",
1031
+ missing_items=("聚合函数 COUNT/SUM/AVG/MIN/MAX",),
1032
+ current_problem="当前 SQL 返回明细行,不能回答数量、最多值、排名或按维度统计问题。",
1033
+ repair_hint="按用户问题中的统计维度 GROUP BY,并用聚合函数生成指标。",
1034
+ )
1035
+
1036
+
1037
+ def format_sql_quality_issue(issue: LowQualitySqlIssue) -> str:
1038
+ parts = [f"当前 SQL 不满足查询口径:{issue.message}"]
1039
+ if issue.missing_items:
1040
+ parts.append(f"缺失口径:{';'.join(issue.missing_items)}")
1041
+ if issue.current_problem:
1042
+ parts.append(f"当前 SQL 的问题:{issue.current_problem}")
1043
+ if issue.repair_hint:
1044
+ parts.append(f"建议修复方向:{issue.repair_hint}")
1045
+ return ";".join(parts)
1046
+
1047
+
1048
+ def _question_requires_aggregate_sql(question: str) -> bool:
1049
+ if not re.search(r"(计数|统计|数量|个数|总数|多少)", question):
1050
+ return False
1051
+ return bool(re.search(r"(最多|最少|最大|最小|排行|排名|对应|按.+?统计|根据.+?计数)", question))
1052
+
1053
+
1054
+ def _concept_quality_check_applies(concept: dict[str, Any], question: str) -> bool:
1055
+ question_text = _compact_quality_match_text(question)
1056
+ if not question_text:
1057
+ return False
1058
+ for example in concept.get("negativeExamples") or []:
1059
+ if _quality_phrase_matches(question_text, example):
1060
+ return False
1061
+ for example in concept.get("positiveExamples") or []:
1062
+ if _quality_phrase_matches(question_text, example):
1063
+ return True
1064
+ for example in concept.get("questionExamples") or []:
1065
+ if _quality_phrase_matches(question_text, example):
1066
+ return True
1067
+ for key in ("comment", "description", "intent", "name", "term"):
1068
+ if _quality_phrase_matches(question_text, concept.get(key)):
1069
+ return True
1070
+ return False
1071
+
1072
+
1073
+ def _quality_phrase_matches(question_text: str, phrase: Any) -> bool:
1074
+ phrase_text = _compact_quality_match_text(str(phrase or ""))
1075
+ return len(phrase_text) >= 2 and phrase_text in question_text
1076
+
1077
+
1078
+ def _compact_quality_match_text(text: str) -> str:
1079
+ normalized = text.translate(str.maketrans("0123456789", "0123456789")).lower()
1080
+ return "".join(re.findall(r"[a-z0-9\u4e00-\u9fff]+", normalized))
1081
+
1082
+
1083
+ def _sql_select_metric_aliases(sql: str) -> list[str]:
1084
+ select_list = _top_level_select_list(sql)
1085
+ if select_list is None:
1086
+ return []
1087
+ aliases: list[str] = []
1088
+ for item in _split_top_level_select_items(select_list):
1089
+ alias_match = re.search(
1090
+ r"\s+as\s+(`[^`]+`|\"[^\"]+\"|'[^']+'|[\u4e00-\u9fff][\w\u4e00-\u9fff]*)\s*$",
1091
+ item.strip(),
1092
+ flags=re.IGNORECASE,
1093
+ )
1094
+ if alias_match is not None:
1095
+ aliases.append(alias_match.group(1).strip("`\"'"))
1096
+ return aliases
1097
+
1098
+
1099
+ def _replace_primary_metric_alias(sql: str, alias: str) -> str:
1100
+ select_list = _top_level_select_list(sql)
1101
+ if select_list is None:
1102
+ return sql
1103
+ items = _split_top_level_select_items(select_list)
1104
+ if not items:
1105
+ return sql
1106
+ last_item = items[-1].strip()
471
1107
  updated_last = re.sub(
472
1108
  r"\s+as\s+(`[^`]+`|\"[^\"]+\"|'[^']+'|[\u4e00-\u9fff][\w\u4e00-\u9fff]*)\s*$",
473
1109
  f" AS {alias}",
@@ -487,52 +1123,38 @@ def _replace_primary_metric_alias(sql: str, alias: str) -> str:
487
1123
  )
488
1124
 
489
1125
 
490
- def normalize_job_count_sql(sql: str, question: str, *, schema: dict[str, Any] | None = None) -> str:
491
- if not _is_job_count_intent_question(question) or not _sql_counts_job_basic_info(sql):
492
- return sql
493
- preferred_alias = _preferred_count_alias(question)
494
- normalized = sql
495
- if not _user_requests_recruiting_job_count(question, schema):
496
- normalized = _strip_job_status_filters(normalized)
497
- if preferred_alias:
498
- normalized = _replace_primary_metric_alias(normalized, preferred_alias)
499
- elif any("在招" in alias for alias in _sql_select_metric_aliases(normalized)):
500
- normalized = _replace_primary_metric_alias(normalized, "总数")
501
- elif preferred_alias:
502
- normalized = _replace_primary_metric_alias(normalized, preferred_alias)
503
- return normalized
504
-
505
-
506
- def _requested_job_status_values(question: str, schema: dict[str, Any] | None) -> set[str] | None:
507
- if not schema:
508
- return None
509
- status_enum = _status_enum(schema, "job_basic_info.status")
510
- if status_enum is None:
511
- return None
512
- matched: set[str] = set()
513
- for item in status_enum:
514
- value = item.get("value")
515
- labels = _enum_label_terms(str(item.get("label") or ""))
516
- if value is None or not labels:
517
- continue
518
- if any(label and label in question for label in labels):
519
- matched.add(str(value))
520
- return matched or None
521
-
522
-
523
- def _status_enum(schema: dict[str, Any], field: str) -> list[dict[str, Any]] | None:
1126
+ def _schema_enums(schema: dict[str, Any]) -> list[dict[str, Any]]:
524
1127
  enums = schema.get("enums")
525
- if not isinstance(enums, list):
526
- return None
527
- for enum in enums:
528
- if not isinstance(enum, dict):
1128
+ if isinstance(enums, list):
1129
+ return [enum for enum in enums if isinstance(enum, dict)]
1130
+ structure = schema.get("structure")
1131
+ concepts = structure.get("concepts") if isinstance(structure, dict) else None
1132
+ if not isinstance(concepts, list):
1133
+ return []
1134
+ by_field: dict[str, dict[Any, str]] = {}
1135
+ for concept in concepts:
1136
+ if not isinstance(concept, dict):
529
1137
  continue
530
- if str(enum.get("field") or "").lower() != field.lower():
1138
+ enum_refs = concept.get("enumRefs")
1139
+ if not isinstance(enum_refs, list):
531
1140
  continue
532
- values = enum.get("values")
533
- if isinstance(values, list):
534
- return [value for value in values if isinstance(value, dict)]
535
- return None
1141
+ label_parts = [
1142
+ str(concept.get("comment") or ""),
1143
+ str(concept.get("name") or ""),
1144
+ str(concept.get("description") or ""),
1145
+ ]
1146
+ for enum_ref in enum_refs:
1147
+ if not isinstance(enum_ref, dict):
1148
+ continue
1149
+ field_name = str(enum_ref.get("field") or "")
1150
+ if not field_name or "value" not in enum_ref:
1151
+ continue
1152
+ label = " / ".join(part for part in label_parts + [str(enum_ref.get("meaning") or "")] if part)
1153
+ by_field.setdefault(field_name, {})[enum_ref["value"]] = label
1154
+ return [
1155
+ {"field": field_name, "values": [{"value": value, "label": label} for value, label in values.items()]}
1156
+ for field_name, values in by_field.items()
1157
+ ]
536
1158
 
537
1159
 
538
1160
  def _enum_label_terms(label: str) -> set[str]:
@@ -541,26 +1163,18 @@ def _enum_label_terms(label: str) -> set[str]:
541
1163
  return {term for term in terms if term}
542
1164
 
543
1165
 
544
- def _sql_status_filter_values(sql: str, job_aliases: set[str]) -> set[str] | None:
545
- values: set[str] = set()
546
- alias_pattern = "|".join(re.escape(alias) for alias in sorted(job_aliases, key=len, reverse=True))
547
- columns = [r"(?<!\.)\bstatus"]
548
- if alias_pattern:
549
- columns.append(rf"\b(?:{alias_pattern})\.status")
550
- for column in columns:
551
- for match in re.finditer(rf"{column}\s*=\s*(?P<value>-?\d+)\b", sql, flags=re.IGNORECASE):
552
- values.add(match.group("value"))
553
- for match in re.finditer(rf"{column}\s+in\s*\((?P<values>[^)]*)\)", sql, flags=re.IGNORECASE):
554
- parsed = re.findall(r"-?\d+", match.group("values"))
555
- values.update(parsed)
556
- return values or None
557
-
558
-
559
1166
  def validate_sql_against_schema(sql: str, schema: dict[str, Any]) -> list[SchemaValidationIssue]:
560
1167
  issues: list[SchemaValidationIssue] = []
1168
+ for subquery in _sql_derived_subqueries(sql).values():
1169
+ issues.extend(validate_sql_against_schema(subquery, schema))
561
1170
  table_aliases = _sql_table_aliases(sql)
562
- schema_tables = _table_names(schema)
563
- for table in table_aliases.values():
1171
+ derived_columns = _sql_derived_subquery_aliases(sql)
1172
+ schema_tables = _schema_table_column_map(schema)
1173
+ for alias, columns in derived_columns.items():
1174
+ schema_tables[alias] = columns
1175
+ for table in set(table_aliases.values()):
1176
+ if table in derived_columns:
1177
+ continue
564
1178
  if table not in schema_tables:
565
1179
  issues.append(
566
1180
  SchemaValidationIssue(
@@ -568,118 +1182,1056 @@ def validate_sql_against_schema(sql: str, schema: dict[str, Any]) -> list[Schema
568
1182
  message=f"SQL 使用了 schema 中不存在的表:{table}",
569
1183
  )
570
1184
  )
1185
+ issues.extend(_schema_missing_table_reference_issues(sql, table_aliases))
1186
+ issues.extend(_schema_unknown_column_issues(sql, table_aliases, schema_tables))
1187
+ for message in _list_join_on_violations(sql):
1188
+ issues.append(SchemaValidationIssue(code="JOIN_ON_FILTER", message=message))
1189
+ if not any(issue.code == "UNKNOWN_TABLE" for issue in issues):
1190
+ issues.extend(_schema_invalid_join_issues(sql, table_aliases, schema))
1191
+ return issues
1192
+
571
1193
 
572
- if {"mvp_applied_jobs_by_helped_account", "job_basic_info"} <= set(table_aliases.values()):
573
- join_pairs = _sql_equality_pairs(sql, table_aliases)
574
- allowed_pairs = _schema_allowed_join_pairs(schema)
575
- work_order_job_pairs = {
576
- pair
577
- for pair in join_pairs
578
- if {pair[0][0], pair[1][0]} == {"mvp_applied_jobs_by_helped_account", "job_basic_info"}
579
- and (pair[0][1] == "job_id" or pair[1][1] == "job_id")
1194
+ def _schema_table_column_map(schema: dict[str, Any]) -> dict[str, set[str]]:
1195
+ column_map: dict[str, set[str]] = {}
1196
+ tables = schema.get("tables")
1197
+ if not isinstance(tables, list):
1198
+ nested_schema = schema.get("schema")
1199
+ if isinstance(nested_schema, dict) and isinstance(nested_schema.get("tables"), list):
1200
+ tables = nested_schema["tables"]
1201
+ else:
1202
+ tables = []
1203
+ for table in tables:
1204
+ if not isinstance(table, dict):
1205
+ continue
1206
+ name = table.get("name") or table.get("tableName")
1207
+ if not name:
1208
+ continue
1209
+ table_key = str(name).lower()
1210
+ columns = table.get("columns")
1211
+ if not isinstance(columns, list):
1212
+ continue
1213
+ column_map[table_key] = {
1214
+ str(column.get("columnName") or column.get("name") or "").lower()
1215
+ for column in columns
1216
+ if isinstance(column, dict) and (column.get("columnName") or column.get("name"))
580
1217
  }
581
- if not any(pair in allowed_pairs for pair in work_order_job_pairs):
1218
+ return column_map
1219
+
1220
+
1221
+ def _schema_missing_table_reference_issues(
1222
+ sql: str,
1223
+ table_aliases: dict[str, str],
1224
+ ) -> list[SchemaValidationIssue]:
1225
+ declared = set(table_aliases)
1226
+ issues: list[SchemaValidationIssue] = []
1227
+ seen: set[tuple[str, str]] = set()
1228
+ for alias, column in _sql_qualified_column_refs(sql):
1229
+ if alias in declared:
1230
+ continue
1231
+ key = (alias, column)
1232
+ if key in seen:
1233
+ continue
1234
+ seen.add(key)
1235
+ issues.append(
1236
+ SchemaValidationIssue(
1237
+ code="MISSING_TABLE_REFERENCE",
1238
+ message=(
1239
+ "SQL 引用了未在 FROM/JOIN 中声明的表或别名:"
1240
+ f"{alias}.{column};如果需要该字段必须先 JOIN 对应表,否则删除该引用。"
1241
+ ),
1242
+ )
1243
+ )
1244
+ return issues
1245
+
1246
+
1247
+ def _schema_unknown_column_issues(
1248
+ sql: str,
1249
+ table_aliases: dict[str, str],
1250
+ schema_tables: dict[str, set[str]],
1251
+ ) -> list[SchemaValidationIssue]:
1252
+ issues: list[SchemaValidationIssue] = []
1253
+ seen: set[tuple[str, str]] = set()
1254
+ for alias, column in _sql_qualified_column_refs(sql):
1255
+ table = table_aliases.get(alias)
1256
+ if table is None or table not in schema_tables:
1257
+ continue
1258
+ key = (table, column)
1259
+ if key in seen:
1260
+ continue
1261
+ seen.add(key)
1262
+ if column not in schema_tables[table]:
582
1263
  issues.append(
583
1264
  SchemaValidationIssue(
584
- code="JOIN_NOT_IN_SCHEMA",
585
- message="报名工单和岗位表必须使用 schema 声明的 mvp_applied_jobs_by_helped_account.job_id = job_basic_info.job_id 关联。",
1265
+ code="UNKNOWN_COLUMN",
1266
+ message=f"SQL 使用了 schema 中不存在的列:{table}.{column}",
586
1267
  )
587
1268
  )
588
1269
  return issues
589
1270
 
590
1271
 
591
- def _has_work_order_job_id_to_job_pk_join(normalized_sql: str) -> bool:
592
- table_aliases = _sql_table_aliases(normalized_sql)
593
- pairs = _sql_equality_pairs(normalized_sql, table_aliases)
594
- return any(
595
- {left[0], right[0]} == {"mvp_applied_jobs_by_helped_account", "job_basic_info"}
596
- and (
597
- (left[0] == "mvp_applied_jobs_by_helped_account" and left[1] == "job_id" and right[1] == "id")
598
- or (right[0] == "mvp_applied_jobs_by_helped_account" and right[1] == "job_id" and left[1] == "id")
599
- )
600
- for left, right in pairs
601
- )
1272
+ def _schema_invalid_join_issues(
1273
+ sql: str,
1274
+ table_aliases: dict[str, str],
1275
+ schema: dict[str, Any],
1276
+ ) -> list[SchemaValidationIssue]:
1277
+ allowed_pairs = _schema_allowed_join_pairs(schema)
1278
+ if not allowed_pairs:
1279
+ return []
1280
+ derived_aliases = set(_sql_derived_subquery_aliases(sql))
1281
+ issues: list[SchemaValidationIssue] = []
1282
+ seen: set[tuple[tuple[str, str], tuple[str, str]]] = set()
1283
+ for pair in _sql_equality_pairs(sql, table_aliases):
1284
+ if pair in seen:
1285
+ continue
1286
+ seen.add(pair)
1287
+ if pair in allowed_pairs:
1288
+ continue
1289
+ left, right = pair
1290
+ if left[0] in derived_aliases or right[0] in derived_aliases:
1291
+ continue
1292
+ issues.append(
1293
+ SchemaValidationIssue(
1294
+ code="INVALID_JOIN",
1295
+ message=(
1296
+ "SQL JOIN 使用了 schema relations / joinPattern 未定义的关联:"
1297
+ f"{left[0]}.{left[1]} = {right[0]}.{right[1]}"
1298
+ ),
1299
+ )
1300
+ )
1301
+ return issues
1302
+
1303
+
1304
+ def _sql_qualified_column_refs(sql: str) -> list[tuple[str, str]]:
1305
+ refs: list[tuple[str, str]] = []
1306
+ seen: set[tuple[str, str]] = set()
1307
+ derived_spans = _sql_derived_subquery_spans(sql)
1308
+ for match in re.finditer(
1309
+ r"\b([a-zA-Z_][a-zA-Z0-9_]*)\.((?:[a-zA-Z_][a-zA-Z0-9_]*\.)*[a-zA-Z_][a-zA-Z0-9_]*)\b",
1310
+ sql,
1311
+ flags=re.IGNORECASE,
1312
+ ):
1313
+ if _position_in_spans(match.start(), derived_spans):
1314
+ continue
1315
+ alias = match.group(1).lower()
1316
+ column_path = match.group(2).lower()
1317
+ column = column_path.rsplit(".", 1)[-1]
1318
+ key = (alias, column)
1319
+ if key in seen:
1320
+ continue
1321
+ seen.add(key)
1322
+ refs.append(key)
1323
+ return refs
1324
+
1325
+
1326
+ def _sql_table_aliases(sql: str) -> dict[str, str]:
1327
+ aliases = dict(_sql_top_level_table_aliases(sql))
1328
+ for alias in _sql_derived_subquery_aliases(sql):
1329
+ aliases[alias] = alias
1330
+ return aliases
1331
+
1332
+
1333
+ def _sql_top_level_table_aliases(sql: str) -> dict[str, str]:
1334
+ aliases: dict[str, str] = {}
1335
+ depth = 0
1336
+ quote: str | None = None
1337
+ index = 0
1338
+ length = len(sql)
1339
+ while index < length:
1340
+ char = sql[index]
1341
+ if quote is not None:
1342
+ if char == quote:
1343
+ quote = None
1344
+ elif char == "\\":
1345
+ index += 1
1346
+ index += 1
1347
+ continue
1348
+ if char in ("'", '"', "`"):
1349
+ quote = char
1350
+ index += 1
1351
+ continue
1352
+ if char == "(":
1353
+ depth += 1
1354
+ index += 1
1355
+ continue
1356
+ if char == ")":
1357
+ depth = max(0, depth - 1)
1358
+ index += 1
1359
+ continue
1360
+ if depth == 0:
1361
+ match = re.match(
1362
+ r"(?i)(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\s+(?:as\s+)?([a-zA-Z_][a-zA-Z0-9_]*))?",
1363
+ sql[index:],
1364
+ )
1365
+ if match:
1366
+ table = match.group(1).lower()
1367
+ alias = (match.group(2) or table).lower()
1368
+ if alias in {"on", "where", "left", "right", "inner", "outer", "join"}:
1369
+ alias = table
1370
+ aliases[alias] = table
1371
+ aliases[table] = table
1372
+ index += match.end()
1373
+ continue
1374
+ index += 1
1375
+ return aliases
1376
+
1377
+
1378
+ def _sql_derived_subquery_aliases(sql: str) -> dict[str, set[str]]:
1379
+ return {
1380
+ alias: _extract_subquery_output_columns(subquery)
1381
+ for alias, subquery in _sql_derived_subqueries(sql).items()
1382
+ }
1383
+
1384
+
1385
+ def _sql_derived_subqueries(sql: str) -> dict[str, str]:
1386
+ derived: dict[str, str] = {}
1387
+ for alias, subquery_start, subquery_end in _sql_derived_subquery_matches(sql):
1388
+ derived[alias] = sql[subquery_start + 1 : subquery_end]
1389
+ return derived
1390
+
1391
+
1392
+ def _sql_derived_subquery_spans(sql: str) -> list[tuple[int, int]]:
1393
+ return [
1394
+ (subquery_start + 1, subquery_end)
1395
+ for _alias, subquery_start, subquery_end in _sql_derived_subquery_matches(sql)
1396
+ ]
1397
+
1398
+
1399
+ _DERIVED_SUBQUERY_TRAILING = (
1400
+ r"on\b"
1401
+ r"|(?:(?:left|right|inner|outer|cross|natural)\s+)?join\b"
1402
+ r"|where\b|group\b|order\b|having\b|limit\b"
1403
+ r"|,"
1404
+ r"|\)(?!\s*[a-zA-Z_])"
1405
+ r"|;"
1406
+ )
1407
+ _DERIVED_SUBQUERY_ALIAS_PATTERN = re.compile(
1408
+ rf"\)\s*(?:as\s+)?([a-zA-Z_][a-zA-Z0-9_]*)(?:\s+(?:{_DERIVED_SUBQUERY_TRAILING})|\s*$)",
1409
+ flags=re.IGNORECASE,
1410
+ )
1411
+ _RESERVED_DERIVED_ALIASES = frozenset(
1412
+ {"on", "where", "left", "right", "inner", "outer", "join", "select", "cross", "natural", "group", "order", "having", "limit"}
1413
+ )
1414
+
1415
+
1416
+ def _sql_derived_subquery_matches(sql: str) -> list[tuple[str, int, int]]:
1417
+ matches: list[tuple[str, int, int]] = []
1418
+ seen_ranges: set[tuple[int, int]] = set()
1419
+ for match in _DERIVED_SUBQUERY_ALIAS_PATTERN.finditer(sql):
1420
+ alias = match.group(1).lower()
1421
+ if alias in _RESERVED_DERIVED_ALIASES:
1422
+ continue
1423
+ subquery_start = _find_matching_open_paren(sql, match.start())
1424
+ if subquery_start is None:
1425
+ continue
1426
+ range_key = (subquery_start, match.start())
1427
+ if range_key in seen_ranges:
1428
+ continue
1429
+ seen_ranges.add(range_key)
1430
+ matches.append((alias, subquery_start, match.start()))
1431
+ return matches
1432
+
1433
+
1434
+ def _position_in_spans(position: int, spans: list[tuple[int, int]]) -> bool:
1435
+ return any(start <= position < end for start, end in spans)
1436
+
1437
+
1438
+ def _find_matching_open_paren(sql: str, close_index: int) -> int | None:
1439
+ depth = 0
1440
+ for index in range(close_index, -1, -1):
1441
+ char = sql[index]
1442
+ if char == ")":
1443
+ depth += 1
1444
+ elif char == "(":
1445
+ depth -= 1
1446
+ if depth == 0:
1447
+ return index
1448
+ return None
1449
+
1450
+
1451
+ def _extract_subquery_output_columns(subquery: str) -> set[str]:
1452
+ select_match = re.search(r"\bselect\b(.*?)\bfrom\b", subquery, flags=re.IGNORECASE | re.DOTALL)
1453
+ if select_match is None:
1454
+ return set()
1455
+ columns: set[str] = set()
1456
+ for expr in _split_select_items(select_match.group(1)):
1457
+ cleaned = expr.strip()
1458
+ if not cleaned:
1459
+ continue
1460
+ alias_match = re.search(r"\bas\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*$", cleaned, flags=re.IGNORECASE)
1461
+ if alias_match:
1462
+ columns.add(alias_match.group(1).lower())
1463
+ continue
1464
+ qualified_match = re.match(
1465
+ r"^([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)$",
1466
+ cleaned,
1467
+ flags=re.IGNORECASE,
1468
+ )
1469
+ if qualified_match:
1470
+ columns.add(qualified_match.group(2).lower())
1471
+ continue
1472
+ bare_match = re.match(r"^([a-zA-Z_][a-zA-Z0-9_]*)$", cleaned, flags=re.IGNORECASE)
1473
+ if bare_match:
1474
+ columns.add(bare_match.group(1).lower())
1475
+ return columns
1476
+
1477
+
1478
+ def _sql_equality_pairs(sql: str, table_aliases: dict[str, str]) -> set[tuple[tuple[str, str], tuple[str, str]]]:
1479
+ pairs: set[tuple[tuple[str, str], tuple[str, str]]] = set()
1480
+ derived_spans = _sql_derived_subquery_spans(sql)
1481
+ for match in re.finditer(
1482
+ r"\b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b",
1483
+ sql,
1484
+ flags=re.IGNORECASE,
1485
+ ):
1486
+ if _position_in_spans(match.start(), derived_spans):
1487
+ continue
1488
+ left_alias, left_column, right_alias, right_column = (part.lower() for part in match.groups())
1489
+ left_table = table_aliases.get(left_alias)
1490
+ right_table = table_aliases.get(right_alias)
1491
+ if left_table is None or right_table is None:
1492
+ continue
1493
+ pairs.add(_canonical_join_pair((left_table, left_column), (right_table, right_column)))
1494
+ return pairs
1495
+
1496
+
1497
+ def _schema_allowed_join_pairs(schema: dict[str, Any]) -> set[tuple[tuple[str, str], tuple[str, str]]]:
1498
+ pairs: set[tuple[tuple[str, str], tuple[str, str]]] = set()
1499
+ relations = _schema_relations(schema)
1500
+ if isinstance(relations, list):
1501
+ for relation in relations:
1502
+ if not isinstance(relation, dict):
1503
+ continue
1504
+ left_table, left_column = _relation_left(relation)
1505
+ right_table, right_column = _relation_right(relation)
1506
+ if left_table and left_column and right_table and right_column:
1507
+ pairs.add(_canonical_join_pair((left_table, left_column), (right_table, right_column)))
1508
+ for section in (_schema_metrics(schema), _schema_business_terms(schema), _schema_concepts(schema), _schema_examples(schema)):
1509
+ if isinstance(section, list):
1510
+ for item in section:
1511
+ if isinstance(item, dict):
1512
+ pairs.update(_join_pairs_from_text(str(item)))
1513
+ pairs.update(_schema_example_join_pattern_pairs(schema))
1514
+ pairs.update(_schema_work_order_failure_reason_join_pairs(schema))
1515
+ pairs.update(_schema_inferred_foreign_key_join_pairs(schema))
1516
+ return pairs
1517
+
1518
+
1519
+ def _schema_inferred_foreign_key_join_pairs(
1520
+ schema: dict[str, Any],
1521
+ ) -> set[tuple[tuple[str, str], tuple[str, str]]]:
1522
+ """Allow `{ref_table}_id` columns to join `{ref_table}.id` when no explicit relation exists."""
1523
+ pairs: set[tuple[tuple[str, str], tuple[str, str]]] = set()
1524
+ table_names = {
1525
+ str(table.get("tableName") or table.get("name") or "")
1526
+ for table in schema.get("tables") or []
1527
+ if isinstance(table, dict) and (table.get("tableName") or table.get("name"))
1528
+ }
1529
+ for table in schema.get("tables") or []:
1530
+ if not isinstance(table, dict):
1531
+ continue
1532
+ table_name = str(table.get("tableName") or table.get("name") or "")
1533
+ if not table_name:
1534
+ continue
1535
+ for column in table.get("columns") or []:
1536
+ if not isinstance(column, dict):
1537
+ continue
1538
+ column_name = str(column.get("columnName") or column.get("name") or "")
1539
+ if not column_name.endswith("_id") or column_name == "id":
1540
+ continue
1541
+ ref_table = column_name[:-3]
1542
+ if ref_table not in table_names:
1543
+ continue
1544
+ pairs.add(_canonical_join_pair((table_name, column_name), (ref_table, "id")))
1545
+ return pairs
1546
+
1547
+
1548
+ def _schema_work_order_failure_reason_join_pairs(
1549
+ schema: dict[str, Any],
1550
+ ) -> set[tuple[tuple[str, str], tuple[str, str]]]:
1551
+ pairs: set[tuple[tuple[str, str], tuple[str, str]]] = set()
1552
+ mappings = _schema_structure(schema).get("workOrderFailureReasonMappings")
1553
+ if not isinstance(mappings, list):
1554
+ return pairs
1555
+ for mapping in mappings:
1556
+ if not isinstance(mapping, dict):
1557
+ continue
1558
+ reason_join = mapping.get("reasonJoin")
1559
+ if isinstance(reason_join, str) and reason_join.strip():
1560
+ pairs.update(_join_pairs_from_text(reason_join))
1561
+ unpivot = _schema_structure(schema).get("workOrderFailureReasonUnpivot")
1562
+ if isinstance(unpivot, dict):
1563
+ base_table = str(unpivot.get("baseTable") or "mvp_applied_jobs_by_helped_account")
1564
+ for branch in unpivot.get("branches") or []:
1565
+ if not isinstance(branch, dict):
1566
+ continue
1567
+ reason_field = str(branch.get("reasonField") or "")
1568
+ if reason_field:
1569
+ pairs.add(
1570
+ _canonical_join_pair(
1571
+ ("failure_reasons", "id"),
1572
+ (base_table, reason_field),
1573
+ )
1574
+ )
1575
+ return pairs
1576
+
1577
+
1578
+ def _schema_example_join_pattern_pairs(schema: dict[str, Any]) -> set[tuple[tuple[str, str], tuple[str, str]]]:
1579
+ pairs: set[tuple[tuple[str, str], tuple[str, str]]] = set()
1580
+ example = schema.get("example")
1581
+ examples: list[Any] = []
1582
+ if isinstance(example, dict) and isinstance(example.get("examples"), list):
1583
+ examples = example["examples"]
1584
+ examples.extend(_schema_examples(schema))
1585
+ for item in examples:
1586
+ if not isinstance(item, dict):
1587
+ continue
1588
+ join_pattern = item.get("joinPattern")
1589
+ if isinstance(join_pattern, dict):
1590
+ pairs.update(_join_pattern_equality_pairs(join_pattern))
1591
+ return pairs
1592
+
1593
+
1594
+ def _join_pattern_equality_pairs(join_pattern: dict[str, Any]) -> set[tuple[tuple[str, str], tuple[str, str]]]:
1595
+ aliases = _parse_table_alias_clause(str(join_pattern.get("from") or ""))
1596
+ pairs: set[tuple[tuple[str, str], tuple[str, str]]] = set()
1597
+ joins = join_pattern.get("joins")
1598
+ if not isinstance(joins, list):
1599
+ return pairs
1600
+ for join in joins:
1601
+ if not isinstance(join, dict):
1602
+ continue
1603
+ aliases.update(_parse_table_alias_clause(str(join.get("table") or "")))
1604
+ on_conditions = join.get("on")
1605
+ if not isinstance(on_conditions, list):
1606
+ continue
1607
+ for condition in on_conditions:
1608
+ pairs.update(_join_pairs_from_on_text(str(condition), aliases))
1609
+ return pairs
1610
+
1611
+
1612
+ def _parse_table_alias_clause(clause: str) -> dict[str, str]:
1613
+ cleaned = clause.strip()
1614
+ if not cleaned:
1615
+ return {}
1616
+ match = re.match(
1617
+ r"^([a-zA-Z_][a-zA-Z0-9_]*)(?:\s+(?:as\s+)?([a-zA-Z_][a-zA-Z0-9_]*))?$",
1618
+ cleaned,
1619
+ flags=re.IGNORECASE,
1620
+ )
1621
+ if match is None:
1622
+ return {}
1623
+ table = match.group(1).lower()
1624
+ alias = (match.group(2) or table).lower()
1625
+ return {alias: table, table: table}
1626
+
1627
+
1628
+ def _join_pairs_from_on_text(
1629
+ on: str,
1630
+ aliases: dict[str, str],
1631
+ ) -> set[tuple[tuple[str, str], tuple[str, str]]]:
1632
+ pairs: set[tuple[tuple[str, str], tuple[str, str]]] = set()
1633
+ for match in re.finditer(
1634
+ r"\b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b",
1635
+ on,
1636
+ flags=re.IGNORECASE,
1637
+ ):
1638
+ left_alias, left_column, right_alias, right_column = (part.lower() for part in match.groups())
1639
+ left_table = aliases.get(left_alias)
1640
+ right_table = aliases.get(right_alias)
1641
+ if left_table is None or right_table is None:
1642
+ continue
1643
+ pairs.add(_canonical_join_pair((left_table, left_column), (right_table, right_column)))
1644
+ return pairs
1645
+
1646
+
1647
+ def _schema_structure(schema: dict[str, Any]) -> dict[str, Any]:
1648
+ structure = schema.get("structure")
1649
+ return structure if isinstance(structure, dict) else {}
1650
+
1651
+
1652
+ def _schema_relations(schema: dict[str, Any]) -> list[dict[str, Any]]:
1653
+ relations = _schema_structure(schema).get("relations")
1654
+ if isinstance(relations, list):
1655
+ return [relation for relation in relations if isinstance(relation, dict)]
1656
+ joins = schema.get("joins")
1657
+ if isinstance(joins, list):
1658
+ return [join for join in joins if isinstance(join, dict)]
1659
+ return []
1660
+
1661
+
1662
+ def _schema_metrics(schema: dict[str, Any]) -> list[dict[str, Any]]:
1663
+ metrics = _schema_structure(schema).get("metrics")
1664
+ if isinstance(metrics, list):
1665
+ return [metric for metric in metrics if isinstance(metric, dict)]
1666
+ legacy_metrics = schema.get("metrics")
1667
+ if isinstance(legacy_metrics, list):
1668
+ return [metric for metric in legacy_metrics if isinstance(metric, dict)]
1669
+ return []
1670
+
1671
+
1672
+ def _schema_business_terms(schema: dict[str, Any]) -> list[dict[str, Any]]:
1673
+ business_terms = _schema_structure(schema).get("businessTerms")
1674
+ if isinstance(business_terms, list):
1675
+ return [term for term in business_terms if isinstance(term, dict)]
1676
+ legacy_glossary = schema.get("glossary")
1677
+ if isinstance(legacy_glossary, list):
1678
+ return [term for term in legacy_glossary if isinstance(term, dict)]
1679
+ return []
1680
+
1681
+
1682
+ def _schema_concepts(schema: dict[str, Any]) -> list[dict[str, Any]]:
1683
+ concepts = _schema_structure(schema).get("concepts")
1684
+ if isinstance(concepts, list):
1685
+ return [concept for concept in concepts if isinstance(concept, dict)]
1686
+ return []
1687
+
1688
+
1689
+ def _schema_examples(schema: dict[str, Any]) -> list[dict[str, Any]]:
1690
+ example = schema.get("example")
1691
+ if isinstance(example, dict) and isinstance(example.get("examples"), list):
1692
+ return [item for item in example["examples"] if isinstance(item, dict)]
1693
+ legacy_examples = schema.get("examples")
1694
+ if isinstance(legacy_examples, list):
1695
+ return [item for item in legacy_examples if isinstance(item, dict)]
1696
+ return []
1697
+
1698
+
1699
+ def _relation_left(relation: dict[str, Any]) -> tuple[str, str]:
1700
+ table = str(relation.get("leftTable") or "").lower()
1701
+ column = str(relation.get("leftColumn") or relation.get("fromColumn") or "").lower()
1702
+ if table:
1703
+ return table, column
1704
+ return _split_field_ref(str(relation.get("from") or "").lower())
1705
+
1706
+
1707
+ def _relation_right(relation: dict[str, Any]) -> tuple[str, str]:
1708
+ table = str(relation.get("rightTable") or "").lower()
1709
+ column = str(relation.get("rightColumn") or relation.get("toColumn") or "").lower()
1710
+ if table:
1711
+ return table, column
1712
+ return _split_field_ref(str(relation.get("to") or "").lower())
1713
+
1714
+
1715
+ def _split_field_ref(value: str) -> tuple[str, str]:
1716
+ if "." not in value:
1717
+ return value, ""
1718
+ return tuple(value.split(".", 1)) # type: ignore[return-value]
1719
+
1720
+
1721
+ def _join_pairs_from_text(text: str) -> set[tuple[tuple[str, str], tuple[str, str]]]:
1722
+ pairs: set[tuple[tuple[str, str], tuple[str, str]]] = set()
1723
+ for match in re.finditer(
1724
+ r"\b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b",
1725
+ text,
1726
+ flags=re.IGNORECASE,
1727
+ ):
1728
+ left_table, left_column, right_table, right_column = (part.lower() for part in match.groups())
1729
+ pairs.add(_canonical_join_pair((left_table, left_column), (right_table, right_column)))
1730
+ return pairs
1731
+
1732
+
1733
+ def _canonical_join_pair(
1734
+ left: tuple[str, str],
1735
+ right: tuple[str, str],
1736
+ ) -> tuple[tuple[str, str], tuple[str, str]]:
1737
+ return tuple(sorted((left, right))) # type: ignore[return-value]
1738
+
1739
+
1740
+ def question_has_time_range(question: str) -> bool:
1741
+ return _extract_time_range(question) is not None
1742
+
1743
+
1744
+ def extract_time_range(question: str, schema: dict[str, Any] | None = None) -> TimeRange | None:
1745
+ """Parse a question time window; month/day without year defaults to the current year."""
1746
+ return _extract_time_range(question, schema)
1747
+
1748
+
1749
+ def time_range_from_query_plan(query_plan: dict[str, Any] | None) -> TimeRange | None:
1750
+ """Extract a confirmed time window from query-plan filters."""
1751
+ if not isinstance(query_plan, dict):
1752
+ return None
1753
+ start_value = ""
1754
+ end_value = ""
1755
+ field_name = ""
1756
+ for flt in _query_plan_sql_filters(query_plan):
1757
+ if not isinstance(flt, dict):
1758
+ continue
1759
+ if str(flt.get("source") or "") != "question.timeRange":
1760
+ continue
1761
+ operator = str(flt.get("operator") or "").strip()
1762
+ value = str(flt.get("value") or "").strip()
1763
+ field = str(flt.get("field") or "").strip()
1764
+ if operator == ">=":
1765
+ start_value = value
1766
+ field_name = field or field_name
1767
+ elif operator == "<":
1768
+ end_value = value
1769
+ field_name = field or field_name
1770
+ if not start_value or not end_value or not field_name:
1771
+ return None
1772
+ table_name, column = field_name.split(".", 1) if "." in field_name else ("", field_name)
1773
+ return TimeRange(
1774
+ field=column,
1775
+ alias="时间范围",
1776
+ start=start_value,
1777
+ end=end_value,
1778
+ table_name=table_name,
1779
+ )
1780
+
1781
+
1782
+ def resolve_time_range(
1783
+ question: str,
1784
+ schema: dict[str, Any] | None = None,
1785
+ *,
1786
+ query_plan: dict[str, Any] | None = None,
1787
+ ) -> TimeRange | None:
1788
+ plan_range = time_range_from_query_plan(query_plan)
1789
+ if plan_range is not None:
1790
+ return plan_range
1791
+ return extract_time_range(question, schema)
1792
+
1793
+
1794
+ def time_range_plan_filters(
1795
+ question: str,
1796
+ schema: dict[str, Any] | None,
1797
+ *,
1798
+ concepts: list[dict[str, Any]] | None = None,
1799
+ ) -> list[dict[str, str]]:
1800
+ """Build query-plan filters for an extracted time range."""
1801
+ time_range = resolve_time_range_for_question(question, schema, concepts=concepts)
1802
+ if time_range is None:
1803
+ return []
1804
+ qualified_field = (
1805
+ f"{time_range.table_name}.{time_range.field}"
1806
+ if time_range.table_name
1807
+ else time_range.field
1808
+ )
1809
+ label = time_range.alias or "时间范围"
1810
+ return [
1811
+ {
1812
+ "field": qualified_field,
1813
+ "operator": ">=",
1814
+ "value": time_range.start[:10],
1815
+ "description": f"{label}起",
1816
+ "source": "question.timeRange",
1817
+ },
1818
+ {
1819
+ "field": qualified_field,
1820
+ "operator": "<",
1821
+ "value": time_range.end[:10],
1822
+ "description": f"{label}止",
1823
+ "source": "question.timeRange",
1824
+ },
1825
+ ]
1826
+
1827
+
1828
+ _FILLED_JOB_PERIOD_CONCEPT = "filled_job_by_period"
1829
+
1830
+
1831
+ def _query_plan_has_matched_concept(query_plan: dict[str, Any], concept_name: str) -> bool:
1832
+ for concept in query_plan.get("matchedConcepts") or []:
1833
+ if isinstance(concept, dict) and str(concept.get("name") or "") == concept_name:
1834
+ return True
1835
+ return False
1836
+
1837
+
1838
+ def _uses_subquery_period_time_range(sql: str, query_plan: dict[str, Any] | None) -> bool:
1839
+ if isinstance(query_plan, dict) and _query_plan_has_matched_concept(
1840
+ query_plan,
1841
+ _FILLED_JOB_PERIOD_CONCEPT,
1842
+ ):
1843
+ return True
1844
+ return "period_active_apply_count" in sql.lower()
1845
+
1846
+
1847
+ def _period_time_bounds(time_range: TimeRange) -> tuple[str, str]:
1848
+ start = time_range.start
1849
+ end = time_range.end
1850
+ if len(start) <= 10:
1851
+ start = f"{start[:10]} 00:00:00"
1852
+ if len(end) <= 10:
1853
+ end = f"{end[:10]} 00:00:00"
1854
+ return start, end
1855
+
1856
+
1857
+ def _apply_subquery_period_time_range(sql: str, time_range: TimeRange) -> str:
1858
+ """Apply confirmed time bounds inside apply_stat CASE WHEN, not outer WHERE."""
1859
+ stripped = _remove_time_range_filters_from_sql(
1860
+ sql.rstrip().rstrip(";"),
1861
+ time_range,
1862
+ allow_any_alias=True,
1863
+ )
1864
+ period_start, period_end = _period_time_bounds(time_range)
1865
+ result = stripped
1866
+ for old, new in (
1867
+ ("'<period_start>'", f"'{period_start}'"),
1868
+ ("'<period_end>'", f"'{period_end}'"),
1869
+ ("<period_start>", f"'{period_start}'"),
1870
+ ("<period_end>", f"'{period_end}'"),
1871
+ ):
1872
+ result = result.replace(old, new)
1873
+ field_pattern = rf"(?:\w+\.)?{re.escape(time_range.field)}"
1874
+ time_expr = (
1875
+ r"(?:date_add\s*\(\s*curdate\s*\(\s*\)\s*,\s*interval\s+\d+\s+day\s*\)"
1876
+ r"|curdate\s*\(\s*\)|current_date(?:\s*\(\s*\))?|'[^']+')"
1877
+ )
1878
+ return re.sub(
1879
+ rf"(?is)(case\s+when\s+{field_pattern}\s*>=\s*){time_expr}(\s+and\s+{field_pattern}\s*<\s*){time_expr}(\s+then)",
1880
+ lambda match: f"{match.group(1)}'{period_start}'{match.group(2)}'{period_end}'{match.group(3)}",
1881
+ result,
1882
+ )
1883
+
1884
+
1885
+ def apply_time_range_filters_from_plan(sql: str, query_plan: dict[str, Any] | None) -> str:
1886
+ """Force generated SQL to use the confirmed query-plan time filters."""
1887
+ if not isinstance(query_plan, dict):
1888
+ return sql
1889
+ time_range = time_range_from_query_plan(query_plan)
1890
+ if time_range is None:
1891
+ return sql
1892
+ stripped = _remove_time_range_filters_from_sql(sql.rstrip().rstrip(";"), time_range)
1893
+ if _uses_subquery_period_time_range(stripped, query_plan):
1894
+ return _apply_subquery_period_time_range(stripped, time_range)
1895
+ qualified_field = _time_range_sql_qualified_field(stripped, time_range)
1896
+ extra = (
1897
+ f"{qualified_field} >= '{time_range.start[:10]}' "
1898
+ f"AND {qualified_field} < '{time_range.end[:10]}'"
1899
+ )
1900
+ insert_pos = _where_filter_insert_pos(stripped)
1901
+ head = stripped[:insert_pos].rstrip()
1902
+ tail = stripped[insert_pos:].lstrip()
1903
+ if _find_top_level_keyword(head, "where", start=0) is not None:
1904
+ return f"{head} AND {extra} {tail}".strip()
1905
+ return f"{head} WHERE {extra} {tail}".strip()
1906
+
1907
+
1908
+ def apply_time_range_filters_from_question(
1909
+ sql: str,
1910
+ question: str,
1911
+ schema: dict[str, Any] | None,
1912
+ ) -> str:
1913
+ """Force a question-level time range into SQL when queryPlan missed it."""
1914
+ time_range = resolve_time_range_for_sql(question, schema, sql)
1915
+ if time_range is None:
1916
+ return sql
1917
+ query_plan = {
1918
+ "filters": [
1919
+ {
1920
+ "field": _qualified_time_range_field(time_range),
1921
+ "operator": ">=",
1922
+ "value": time_range.start[:10],
1923
+ "source": "question.timeRange",
1924
+ },
1925
+ {
1926
+ "field": _qualified_time_range_field(time_range),
1927
+ "operator": "<",
1928
+ "value": time_range.end[:10],
1929
+ "source": "question.timeRange",
1930
+ },
1931
+ ]
1932
+ }
1933
+ return apply_time_range_filters_from_plan(sql, query_plan)
1934
+
1935
+
1936
+ def resolve_time_range_for_sql(
1937
+ question: str,
1938
+ schema: dict[str, Any] | None,
1939
+ sql: str,
1940
+ ) -> TimeRange | None:
1941
+ if not schema:
1942
+ return None
1943
+ from .schema_context_retriever import match_concepts_for_question
1944
+
1945
+ concepts = match_concepts_for_question(_schema_concepts(schema), question)
1946
+ sql_schema = _schema_limited_to_sql_tables(schema, sql)
1947
+ if sql_schema is None and _sql_referenced_table_names(sql):
1948
+ return None
1949
+ if sql_schema is not None:
1950
+ return resolve_time_range_for_question(
1951
+ question,
1952
+ sql_schema,
1953
+ concepts=_concepts_limited_to_schema_tables(concepts, sql_schema),
1954
+ )
1955
+ return resolve_time_range_for_question(question, schema, concepts=concepts)
1956
+
1957
+
1958
+ def _concepts_limited_to_schema_tables(
1959
+ concepts: list[dict[str, Any]],
1960
+ schema: dict[str, Any],
1961
+ ) -> list[dict[str, Any]]:
1962
+ table_names = {name.lower() for name in _table_names(schema)}
1963
+ if not table_names:
1964
+ return []
1965
+ limited: list[dict[str, Any]] = []
1966
+ for concept in concepts:
1967
+ qualified = str(concept.get("timeRangeField") or "").strip()
1968
+ if "." not in qualified:
1969
+ limited.append(concept)
1970
+ continue
1971
+ table_name = qualified.split(".", 1)[0].lower()
1972
+ if table_name in table_names:
1973
+ limited.append(concept)
1974
+ return limited
1975
+
1976
+
1977
+ def _qualified_time_range_field(time_range: TimeRange) -> str:
1978
+ if time_range.table_name:
1979
+ return f"{time_range.table_name}.{time_range.field}"
1980
+ return time_range.field
1981
+
1982
+
1983
+ def _where_filter_insert_pos(sql: str) -> int:
1984
+ positions = [
1985
+ pos
1986
+ for keyword in ("group by", "order by", "limit")
1987
+ if (pos := _find_top_level_keyword(sql, keyword, start=0)) is not None
1988
+ ]
1989
+ return min(positions) if positions else len(sql)
1990
+
1991
+
1992
+ def _remove_time_range_filters_from_sql(
1993
+ sql: str,
1994
+ time_range: TimeRange,
1995
+ *,
1996
+ allow_any_alias: bool = False,
1997
+ ) -> str:
1998
+ where_index = _find_top_level_keyword(sql, "where", start=0)
1999
+ if where_index is None:
2000
+ return sql
2001
+ clause_start = where_index + len("where")
2002
+ clause_end = _where_filter_insert_pos(sql[clause_start:])
2003
+ if clause_end < len(sql[clause_start:]):
2004
+ clause_end += clause_start
2005
+ else:
2006
+ clause_end = len(sql)
2007
+ where_body = sql[clause_start:clause_end].strip()
2008
+ if not where_body:
2009
+ return sql
2010
+ predicates = _split_top_level_and_predicates(where_body)
2011
+ kept = [
2012
+ predicate.strip()
2013
+ for predicate in predicates
2014
+ if predicate.strip()
2015
+ and not _is_time_range_predicate(
2016
+ predicate,
2017
+ sql=sql,
2018
+ time_range=time_range,
2019
+ allow_any_alias=allow_any_alias,
2020
+ )
2021
+ ]
2022
+ prefix = sql[:where_index].rstrip()
2023
+ suffix = sql[clause_end:].lstrip()
2024
+ if not kept:
2025
+ return f"{prefix} {suffix}".strip()
2026
+ return f"{prefix} WHERE {' AND '.join(kept)} {suffix}".strip()
2027
+
2028
+
2029
+ def _split_top_level_and_predicates(where_body: str) -> list[str]:
2030
+ predicates: list[str] = []
2031
+ depth = 0
2032
+ quote: str | None = None
2033
+ start = 0
2034
+ index = 0
2035
+ while index < len(where_body):
2036
+ char = where_body[index]
2037
+ if quote is not None:
2038
+ if char == quote:
2039
+ quote = None
2040
+ elif char == "\\":
2041
+ index += 1
2042
+ index += 1
2043
+ continue
2044
+ if char in ("'", '"', "`"):
2045
+ quote = char
2046
+ index += 1
2047
+ continue
2048
+ if char == "(":
2049
+ depth += 1
2050
+ index += 1
2051
+ continue
2052
+ if char == ")":
2053
+ depth = max(0, depth - 1)
2054
+ index += 1
2055
+ continue
2056
+ if depth == 0 and where_body[index : index + 3].lower() == "and":
2057
+ before = where_body[index - 1] if index > 0 else " "
2058
+ after_index = index + 3
2059
+ after = where_body[after_index] if after_index < len(where_body) else " "
2060
+ if not (before.isalnum() or before == "_") and not (after.isalnum() or after == "_"):
2061
+ predicates.append(where_body[start:index])
2062
+ start = after_index
2063
+ index = after_index
2064
+ continue
2065
+ index += 1
2066
+ predicates.append(where_body[start:])
2067
+ return predicates
2068
+
2069
+
2070
+ def _is_time_range_predicate(
2071
+ predicate: str,
2072
+ *,
2073
+ sql: str,
2074
+ time_range: TimeRange,
2075
+ allow_any_alias: bool = False,
2076
+ ) -> bool:
2077
+ field_pattern = _time_range_sql_field_pattern(sql, time_range, allow_any_alias=allow_any_alias)
2078
+ if re.search(field_pattern, predicate, flags=re.IGNORECASE) is None:
2079
+ return False
2080
+ if re.search(r"'20\d{2}-\d{1,2}-\d{1,2}(?:\s+\d{1,2}:\d{2}:\d{2})?'", predicate) is None:
2081
+ return False
2082
+ return bool(re.search(r"(?:\bbetween\b|>=|>|<=|<|=)", predicate, flags=re.IGNORECASE))
602
2083
 
603
2084
 
604
- def _sql_table_aliases(sql: str) -> dict[str, str]:
605
- aliases: dict[str, str] = {}
606
- for match in re.finditer(
607
- r"\b(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\s+(?:as\s+)?([a-zA-Z_][a-zA-Z0-9_]*))?",
608
- sql,
609
- flags=re.IGNORECASE,
610
- ):
611
- table = match.group(1).lower()
612
- alias = (match.group(2) or table).lower()
613
- if alias in {"on", "where", "left", "right", "inner", "outer", "join"}:
614
- alias = table
615
- aliases[alias] = table
616
- aliases[table] = table
617
- return aliases
2085
+ def _time_range_sql_field_pattern(
2086
+ sql: str,
2087
+ time_range: TimeRange,
2088
+ *,
2089
+ allow_any_alias: bool = False,
2090
+ ) -> str:
2091
+ field = re.escape(time_range.field.lower())
2092
+ if allow_any_alias or not time_range.table_name:
2093
+ return rf"(?:\b\w+\.)?{field}\b"
2094
+ aliases = [
2095
+ re.escape(alias)
2096
+ for alias, table in _sql_table_aliases(sql).items()
2097
+ if table == time_range.table_name.lower()
2098
+ ]
2099
+ aliases.append(re.escape(time_range.table_name.lower()))
2100
+ qualifier = "|".join(sorted(set(aliases), key=len, reverse=True))
2101
+ return rf"\b(?:{qualifier})\.{field}\b"
2102
+
2103
+
2104
+ def _time_range_sql_qualified_field(sql: str, time_range: TimeRange) -> str:
2105
+ if not time_range.table_name:
2106
+ return time_range.field
2107
+ aliases = _sql_top_level_table_aliases(sql)
2108
+ alias_candidates = [
2109
+ alias
2110
+ for alias, table in aliases.items()
2111
+ if table == time_range.table_name.lower() and alias != time_range.table_name.lower()
2112
+ ]
2113
+ if alias_candidates:
2114
+ return f"{sorted(alias_candidates, key=len)[0]}.{time_range.field}"
2115
+ return f"{time_range.table_name}.{time_range.field}"
618
2116
 
619
2117
 
620
- def _sql_equality_pairs(sql: str, table_aliases: dict[str, str]) -> set[tuple[tuple[str, str], tuple[str, str]]]:
621
- pairs: set[tuple[tuple[str, str], tuple[str, str]]] = set()
622
- for match in re.finditer(
623
- r"\b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b",
624
- sql,
625
- flags=re.IGNORECASE,
626
- ):
627
- left_alias, left_column, right_alias, right_column = (part.lower() for part in match.groups())
628
- left_table = table_aliases.get(left_alias)
629
- right_table = table_aliases.get(right_alias)
630
- if left_table is None or right_table is None:
2118
+ def sql_missing_query_plan_filters(sql: str, query_plan: dict[str, Any] | None) -> list[str]:
2119
+ """Return table.column LIKE filters from query_plan that are absent in sql."""
2120
+ if not isinstance(query_plan, dict):
2121
+ return []
2122
+ missing: list[str] = []
2123
+ normalized_sql = sql.lower()
2124
+ grouped_filters: dict[str, list[dict[str, Any]]] = {}
2125
+ for flt in _query_plan_sql_filters(query_plan):
2126
+ if not isinstance(flt, dict):
631
2127
  continue
632
- pairs.add(_canonical_join_pair((left_table, left_column), (right_table, right_column)))
633
- return pairs
2128
+ if str(flt.get("operator") or "").upper() != "LIKE":
2129
+ continue
2130
+ logical_group = str(flt.get("logicalGroup") or "").strip()
2131
+ if logical_group:
2132
+ grouped_filters.setdefault(logical_group, []).append(flt)
2133
+ field = str(flt.get("field") or "").strip()
2134
+ if "." not in field:
2135
+ continue
2136
+ table, column = field.split(".", 1)
2137
+ value = str(flt.get("value") or "").strip().strip("%")
2138
+ if not value:
2139
+ continue
2140
+ table_l = table.lower()
2141
+ column_l = column.lower()
2142
+ table_present = bool(re.search(rf"\b{re.escape(table_l)}\b", normalized_sql))
2143
+ column_present = bool(re.search(rf"\b{re.escape(column_l)}\b", normalized_sql))
2144
+ value_present = value.lower() in normalized_sql
2145
+ if not table_present or not column_present or not value_present:
2146
+ missing.append(f"{field} LIKE {flt.get('value')}")
2147
+ for group_name, filters in grouped_filters.items():
2148
+ if len(filters) <= 1:
2149
+ continue
2150
+ group_missing = [
2151
+ f"{flt.get('field')} LIKE {flt.get('value')}"
2152
+ for flt in filters
2153
+ if f"{flt.get('field')} LIKE {flt.get('value')}" in missing
2154
+ ]
2155
+ if group_missing:
2156
+ continue
2157
+ if not _or_group_filters_connected_by_or(normalized_sql, filters):
2158
+ missing.append(f"{group_name} requires OR between matched entities")
2159
+ return missing
634
2160
 
635
2161
 
636
- def _schema_allowed_join_pairs(schema: dict[str, Any]) -> set[tuple[tuple[str, str], tuple[str, str]]]:
637
- pairs: set[tuple[tuple[str, str], tuple[str, str]]] = set()
638
- joins = schema.get("joins", [])
639
- if isinstance(joins, list):
640
- for join in joins:
641
- if not isinstance(join, dict):
642
- continue
643
- left_table = str(join.get("leftTable") or join.get("from") or "").lower()
644
- left_column = str(join.get("leftColumn") or join.get("fromColumn") or "").lower()
645
- right_table = str(join.get("rightTable") or join.get("to") or "").lower()
646
- right_column = str(join.get("rightColumn") or join.get("toColumn") or "").lower()
647
- if left_table and left_column and right_table and right_column:
648
- pairs.add(_canonical_join_pair((left_table, left_column), (right_table, right_column)))
649
- for section_name in ("metrics", "glossary", "examples"):
650
- section = schema.get(section_name, [])
651
- if isinstance(section, list):
652
- for item in section:
653
- if isinstance(item, dict):
654
- pairs.update(_join_pairs_from_text(str(item)))
655
- return pairs
2162
+ def sql_satisfies_query_plan_filters(sql: str, query_plan: dict[str, Any] | None) -> bool:
2163
+ return not sql_missing_query_plan_filters(sql, query_plan)
656
2164
 
657
2165
 
658
- def _join_pairs_from_text(text: str) -> set[tuple[tuple[str, str], tuple[str, str]]]:
659
- pairs: set[tuple[tuple[str, str], tuple[str, str]]] = set()
660
- for match in re.finditer(
661
- r"\b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b",
662
- text,
663
- flags=re.IGNORECASE,
664
- ):
665
- left_table, left_column, right_table, right_column = (part.lower() for part in match.groups())
666
- pairs.add(_canonical_join_pair((left_table, left_column), (right_table, right_column)))
667
- return pairs
2166
+ def _query_plan_sql_filters(query_plan: dict[str, Any]) -> list[Any]:
2167
+ filters = query_plan.get("_sqlFilters")
2168
+ if isinstance(filters, list):
2169
+ return filters
2170
+ filters = query_plan.get("filters")
2171
+ return filters if isinstance(filters, list) else []
668
2172
 
669
2173
 
670
- def _canonical_join_pair(
671
- left: tuple[str, str],
672
- right: tuple[str, str],
673
- ) -> tuple[tuple[str, str], tuple[str, str]]:
674
- return tuple(sorted((left, right))) # type: ignore[return-value]
2174
+ def _or_group_filters_connected_by_or(normalized_sql: str, filters: list[dict[str, Any]]) -> bool:
2175
+ if not re.search(r"\bor\b", normalized_sql):
2176
+ return False
2177
+ positions: list[int] = []
2178
+ for flt in filters:
2179
+ value = str(flt.get("value") or "").strip().strip("%").lower()
2180
+ if not value:
2181
+ continue
2182
+ found = normalized_sql.find(value)
2183
+ if found >= 0:
2184
+ positions.append(found)
2185
+ if len(positions) < 2:
2186
+ return True
2187
+ start, end = min(positions), max(positions)
2188
+ between = normalized_sql[start:end]
2189
+ return bool(re.search(r"\bor\b", between))
675
2190
 
676
2191
 
677
- def question_has_time_range(question: str) -> bool:
678
- return _extract_time_range(question) is not None
2192
+ def sql_missing_query_plan_select_fields(sql: str, query_plan: dict[str, Any] | None) -> list[str]:
2193
+ """Return required SELECT display fields from query_plan that are absent in sql."""
2194
+ if not isinstance(query_plan, dict):
2195
+ return []
2196
+ select_clause = _select_clause(sql).lower()
2197
+ if not select_clause:
2198
+ return []
2199
+ missing: list[str] = []
2200
+ for item in query_plan.get("fields") or []:
2201
+ if not isinstance(item, dict):
2202
+ continue
2203
+ if item.get("required") is not True and str(item.get("source") or "") != "search_entities":
2204
+ continue
2205
+ table = str(item.get("table") or "").strip()
2206
+ field = str(item.get("field") or "").strip()
2207
+ description = str(item.get("description") or "").strip()
2208
+ if not table or not field:
2209
+ continue
2210
+ qualified = f"{table}.{field}".lower()
2211
+ description_present = bool(description and description.lower() in select_clause)
2212
+ qualified_present = qualified in select_clause
2213
+ if not description_present and not qualified_present:
2214
+ missing.append(f"{qualified} AS {description or field}")
2215
+ return missing
2216
+
679
2217
 
2218
+ def sql_satisfies_query_plan_select_fields(sql: str, query_plan: dict[str, Any] | None) -> bool:
2219
+ return not sql_missing_query_plan_select_fields(sql, query_plan)
680
2220
 
681
- def sql_satisfies_time_range(sql: str, question: str, schema: dict[str, Any] | None = None) -> bool:
682
- time_range = _extract_time_range(question, schema)
2221
+
2222
+ def _select_clause(sql: str) -> str:
2223
+ match = re.search(r"\bselect\b(?P<select>.*?)\bfrom\b", sql, flags=re.IGNORECASE | re.DOTALL)
2224
+ return match.group("select") if match else ""
2225
+
2226
+
2227
+ def sql_satisfies_time_range(
2228
+ sql: str,
2229
+ question: str,
2230
+ schema: dict[str, Any] | None = None,
2231
+ *,
2232
+ query_plan: dict[str, Any] | None = None,
2233
+ ) -> bool:
2234
+ time_range = resolve_time_range(question, schema, query_plan=query_plan)
683
2235
  if time_range is None:
684
2236
  return True
685
2237
  normalized = sql.lower()
@@ -688,6 +2240,11 @@ def sql_satisfies_time_range(sql: str, question: str, schema: dict[str, Any] | N
688
2240
  return False
689
2241
  if time_range.start[:10] in sql and time_range.end[:10] in sql:
690
2242
  return True
2243
+ end_date = _parse_date(time_range.end[:10])
2244
+ if end_date is not None:
2245
+ inclusive_end = (end_date - timedelta(days=1)).isoformat()
2246
+ if time_range.start[:10] in sql and inclusive_end in sql:
2247
+ return True
691
2248
  year = time_range.start[:4]
692
2249
  month = str(int(time_range.start[5:7]))
693
2250
  padded_month = time_range.start[5:7]
@@ -697,6 +2254,61 @@ def sql_satisfies_time_range(sql: str, question: str, schema: dict[str, Any] | N
697
2254
  )
698
2255
 
699
2256
 
2257
+ def _schema_limited_to_sql_tables(schema: dict[str, Any], sql: str) -> dict[str, Any] | None:
2258
+ used_tables = _sql_referenced_table_names(sql)
2259
+ if not used_tables:
2260
+ return None
2261
+ tables = schema.get("tables", [])
2262
+ if not isinstance(tables, list):
2263
+ return None
2264
+ matched_tables = []
2265
+ for table in tables:
2266
+ if not isinstance(table, dict):
2267
+ continue
2268
+ table_name = str(table.get("tableName") or table.get("name") or "").lower()
2269
+ if table_name in used_tables:
2270
+ matched_tables.append(table)
2271
+ if not matched_tables:
2272
+ return None
2273
+ limited = dict(schema)
2274
+ limited["tables"] = matched_tables
2275
+ return limited
2276
+
2277
+
2278
+ def _sql_referenced_table_names(sql: str) -> set[str]:
2279
+ normalized = _strip_sql_string_literals(sql)
2280
+ names: set[str] = set()
2281
+ for match in re.finditer(
2282
+ r"\b(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)\b",
2283
+ normalized,
2284
+ flags=re.IGNORECASE,
2285
+ ):
2286
+ names.add(match.group(1).lower())
2287
+ return names
2288
+
2289
+
2290
+ def _strip_sql_string_literals(sql: str) -> str:
2291
+ result: list[str] = []
2292
+ quote: str | None = None
2293
+ index = 0
2294
+ while index < len(sql):
2295
+ char = sql[index]
2296
+ if quote is not None:
2297
+ if char == quote:
2298
+ quote = None
2299
+ result.append(" ")
2300
+ index += 1
2301
+ continue
2302
+ if char in ("'", '"'):
2303
+ quote = char
2304
+ result.append(" ")
2305
+ index += 1
2306
+ continue
2307
+ result.append(char)
2308
+ index += 1
2309
+ return "".join(result)
2310
+
2311
+
700
2312
  def _table_names(schema: dict[str, Any]) -> set[str]:
701
2313
  tables = schema.get("tables", [])
702
2314
  if not isinstance(tables, list):
@@ -719,7 +2331,7 @@ def _table_columns(schema: dict[str, Any], table_name: str) -> list[dict[str, An
719
2331
  if not isinstance(table, dict):
720
2332
  continue
721
2333
  name = table.get("name") or table.get("tableName")
722
- if name == table_name and isinstance(table.get("columns"), list):
2334
+ if str(name).lower() == str(table_name).lower() and isinstance(table.get("columns"), list):
723
2335
  return [column for column in table["columns"] if isinstance(column, dict)]
724
2336
  return []
725
2337
 
@@ -755,55 +2367,75 @@ def _has_column(schema: dict[str, Any], table_name: str, column_name: str) -> bo
755
2367
  return any((column.get("columnName") or column.get("name")) == column_name for column in _table_columns(schema, table_name))
756
2368
 
757
2369
 
758
- def _extract_time_range(question: str, schema: dict[str, Any] | None = None) -> TimeRange | None:
759
- field = _resolve_job_time_field(question, schema or {})
760
- if field is None:
761
- return None
2370
+ def resolve_time_range_for_question(
2371
+ question: str,
2372
+ schema: dict[str, Any] | None = None,
2373
+ *,
2374
+ concepts: list[dict[str, Any]] | None = None,
2375
+ ) -> TimeRange | None:
762
2376
  bounds = _extract_time_bounds(question)
763
2377
  if bounds is None:
764
2378
  return None
765
- return TimeRange(field=field.name, alias=field.alias, start=bounds[0], end=bounds[1])
2379
+ for concept in concepts or []:
2380
+ qualified = str(concept.get("timeRangeField") or "").strip()
2381
+ if "." not in qualified:
2382
+ continue
2383
+ table_name, field_name = qualified.rsplit(".", 1)
2384
+ return TimeRange(
2385
+ field=field_name,
2386
+ alias="时间范围",
2387
+ start=bounds[0],
2388
+ end=bounds[1],
2389
+ table_name=table_name,
2390
+ )
2391
+ field = _resolve_time_field(question, schema or {})
2392
+ if field is None:
2393
+ return None
2394
+ return TimeRange(
2395
+ field=field.name,
2396
+ alias=field.alias,
2397
+ start=bounds[0],
2398
+ end=bounds[1],
2399
+ table_name=field.table_name,
2400
+ )
2401
+
2402
+
2403
+ def _extract_time_range(question: str, schema: dict[str, Any] | None = None) -> TimeRange | None:
2404
+ return resolve_time_range_for_question(question, schema)
766
2405
 
767
2406
 
768
- def _resolve_job_time_field(question: str, schema: dict[str, Any]) -> FieldRef | None:
769
- columns = _table_columns(schema, "job_basic_info")
2407
+ def _resolve_time_field(question: str, schema: dict[str, Any]) -> FieldRef | None:
770
2408
  question_text = _normalize_semantic_text(question)
771
2409
  best: tuple[int, FieldRef] | None = None
772
- for column in columns:
773
- name = str(column.get("columnName") or column.get("name") or "")
774
- if not name:
775
- continue
776
- data_type = str(column.get("dataType") or column.get("type") or "")
777
- comment = str(column.get("comment") or "")
778
- if not _is_time_like_column(name, data_type, comment):
2410
+ for table in schema.get("tables", []) if isinstance(schema.get("tables"), list) else []:
2411
+ if not isinstance(table, dict):
779
2412
  continue
780
- score = _time_field_match_score(question_text, name, comment)
781
- if score <= 0:
2413
+ columns = table.get("columns")
2414
+ if not isinstance(columns, list):
782
2415
  continue
783
- field_ref = FieldRef(name=name, alias=_column_alias(name, comment))
784
- if best is None or score > best[0]:
785
- best = (score, field_ref)
2416
+ for column in columns:
2417
+ if not isinstance(column, dict):
2418
+ continue
2419
+ name = str(column.get("columnName") or column.get("name") or "")
2420
+ if not name:
2421
+ continue
2422
+ data_type = str(column.get("dataType") or column.get("type") or "")
2423
+ comment = str(column.get("comment") or "")
2424
+ if not _is_time_like_column(name, data_type, comment):
2425
+ continue
2426
+ score = _time_field_match_score(question_text, name, comment)
2427
+ if score <= 0:
2428
+ continue
2429
+ table_name = str(table.get("tableName") or table.get("name") or "")
2430
+ field_ref = FieldRef(
2431
+ name=name,
2432
+ alias=_column_alias(name, comment),
2433
+ table_name=table_name,
2434
+ )
2435
+ if best is None or score > best[0]:
2436
+ best = (score, field_ref)
786
2437
  if best is not None:
787
2438
  return best[1]
788
- if _extract_time_bounds(question) is not None and re.search(r"(在招岗位数|岗位供给|招聘人数|招聘门店数|供给)", question):
789
- created_column = _select_column(schema, "job_basic_info", ("创建时间",), ("create_at",))
790
- if created_column is not None:
791
- return FieldRef(created_column, _column_alias(created_column, "创建时间"))
792
- fallback = _select_time_field(question)
793
- if fallback is None:
794
- return None
795
- return fallback
796
-
797
-
798
- def _select_time_field(question: str) -> FieldRef | None:
799
- if re.search(r"(发布|上架)", question):
800
- return FieldRef("publish_at", "发布时间")
801
- if re.search(r"(下架)", question):
802
- return FieldRef("off_at", "下架时间")
803
- if re.search(r"(更新|修改)", question):
804
- return FieldRef("update_at", "更新时间")
805
- if re.search(r"(创建|新建|新增)", question):
806
- return FieldRef("create_at", "创建时间")
807
2439
  return None
808
2440
 
809
2441
 
@@ -818,9 +2450,6 @@ def _is_time_like_column(name: str, data_type: str, comment: str) -> bool:
818
2450
  def _time_field_match_score(question_text: str, name: str, comment: str) -> int:
819
2451
  field_text = _normalize_semantic_text(f"{name}{comment}")
820
2452
  score = 0
821
- for term in ("创建", "发布", "下架", "更新"):
822
- if term in question_text and term in field_text:
823
- score += 100
824
2453
  comment_text = _normalize_semantic_text(comment)
825
2454
  if comment_text and comment_text in question_text:
826
2455
  score += 50
@@ -829,23 +2458,18 @@ def _time_field_match_score(question_text: str, name: str, comment: str) -> int:
829
2458
  score += 30
830
2459
  if "时间" in question_text and "时间" in field_text:
831
2460
  score += 5
2461
+ if "日期" in question_text and ("时间" in field_text or "日期" in field_text):
2462
+ score += 5
2463
+ if "报名" in question_text and "报名" in comment_text:
2464
+ score += 40
2465
+ if re.search(r"(上架|发布|新上架)", question_text):
2466
+ if "publish" in name.lower() or "发布" in comment_text:
2467
+ score += 40
832
2468
  return score
833
2469
 
834
2470
 
835
2471
  def _normalize_semantic_text(text: str) -> str:
836
2472
  normalized = text.lower()
837
- replacements = {
838
- "新建": "创建",
839
- "新增": "创建",
840
- "录入": "创建",
841
- "生成": "创建",
842
- "上架": "发布",
843
- "上线": "发布",
844
- "修改": "更新",
845
- "下线": "下架",
846
- }
847
- for source, target in replacements.items():
848
- normalized = normalized.replace(source, target)
849
2473
  return re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", normalized)
850
2474
 
851
2475
 
@@ -853,24 +2477,73 @@ def _column_alias(name: str, comment: str) -> str:
853
2477
  cleaned = re.sub(r"[。;;,,\s].*$", "", comment.strip())
854
2478
  if cleaned:
855
2479
  return cleaned
856
- fallback_aliases = {
857
- "create_at": "创建时间",
858
- "publish_at": "发布时间",
859
- "off_at": "下架时间",
860
- "update_at": "更新时间",
861
- }
862
- return fallback_aliases.get(name, name)
2480
+ return name
2481
+
2482
+
2483
+ def _normalize_fullwidth_digits(text: str) -> str:
2484
+ return text.translate(str.maketrans("0123456789", "0123456789"))
863
2485
 
864
2486
 
865
2487
  def _extract_time_bounds(question: str) -> tuple[str, str] | None:
2488
+ question = _normalize_fullwidth_digits(question)
866
2489
  date_range = _extract_explicit_date_range(question)
867
2490
  if date_range is not None:
868
2491
  return date_range
2492
+ if re.search(r"(近|最近|过去)\s*(?:1|一)\s*年", question):
2493
+ today = _today()
2494
+ return _format_datetime(_one_year_before(today)), _format_datetime(today + timedelta(days=1))
2495
+ if re.search(r"(今天|今日)", question):
2496
+ today = _today()
2497
+ return _format_datetime(today), _format_datetime(today + timedelta(days=1))
2498
+ if re.search(r"(昨天|昨日)", question):
2499
+ today = _today()
2500
+ yesterday = today - timedelta(days=1)
2501
+ return _format_datetime(yesterday), _format_datetime(today)
869
2502
  relative_month_match = re.search(r"(?P<relative_year>今年|去年|前年|明年|后年)\s*(?P<month>\d{1,2})\s*月(?:份)?", question)
870
2503
  if relative_month_match is not None:
871
2504
  year = _today().year + _relative_year_offset(relative_month_match.group("relative_year"))
872
2505
  month = int(relative_month_match.group("month"))
873
2506
  return _month_bounds(year, month)
2507
+ if not re.search(r"20\d{2}\s*年", question):
2508
+ cn_day_range = re.search(
2509
+ r"(?P<month>\d{1,2})\s*月\s*(?P<start_day>\d{1,2})\s*日?\s*"
2510
+ r"(?:到|至|~|~|-)\s*"
2511
+ r"(?:(?P<end_month>\d{1,2})\s*月\s*)?(?P<end_day>\d{1,2})\s*日?",
2512
+ question,
2513
+ )
2514
+ if cn_day_range is not None:
2515
+ year = _today().year
2516
+ month = int(cn_day_range.group("month"))
2517
+ end_month_text = cn_day_range.group("end_month")
2518
+ end_month = int(end_month_text) if end_month_text else month
2519
+ if end_month != month:
2520
+ return None
2521
+ start_day = int(cn_day_range.group("start_day"))
2522
+ end_day = int(cn_day_range.group("end_day"))
2523
+ try:
2524
+ start = date(year, month, start_day)
2525
+ end = date(year, month, end_day)
2526
+ except ValueError:
2527
+ return None
2528
+ if end >= start:
2529
+ return _format_datetime(start), _format_datetime(end + timedelta(days=1))
2530
+ single_cn_day = re.search(r"(?P<month>\d{1,2})\s*月\s*(?P<day>\d{1,2})\s*日", question)
2531
+ if single_cn_day is not None and not re.search(r"(到|至|~|~|-)", question):
2532
+ year = _today().year
2533
+ try:
2534
+ day = date(year, int(single_cn_day.group("month")), int(single_cn_day.group("day")))
2535
+ except ValueError:
2536
+ day = None
2537
+ if day is not None:
2538
+ return _format_datetime(day), _format_datetime(day + timedelta(days=1))
2539
+ cn_month_only = re.search(
2540
+ r"(?:整个|整)\s*(?P<month>\d{1,2})\s*月(?:份)?|(?P<month_only>\d{1,2})\s*月(?:份)?(?!\s*\d{1,2}\s*日)",
2541
+ question,
2542
+ )
2543
+ if cn_month_only is not None:
2544
+ month_text = cn_month_only.group("month") or cn_month_only.group("month_only")
2545
+ if month_text is not None:
2546
+ return _month_bounds(_today().year, int(month_text))
874
2547
  month_match = re.search(r"(?P<year>20\d{2})\s*年\s*(?P<month>\d{1,2})\s*月(?:份)?", question)
875
2548
  if month_match is None:
876
2549
  month_match = re.search(r"(?P<year>20\d{2})[-/](?P<month>\d{1,2})(?![-/]\d)", question)
@@ -892,6 +2565,13 @@ def _relative_year_offset(relative_year: str) -> int:
892
2565
  return offsets[relative_year]
893
2566
 
894
2567
 
2568
+ def _one_year_before(value: date) -> date:
2569
+ try:
2570
+ return value.replace(year=value.year - 1)
2571
+ except ValueError:
2572
+ return value.replace(year=value.year - 1, day=28)
2573
+
2574
+
895
2575
  def _month_bounds(year: int, month: int) -> tuple[str, str] | None:
896
2576
  if not 1 <= month <= 12:
897
2577
  return None
@@ -933,339 +2613,16 @@ def _today() -> date:
933
2613
  return date.today()
934
2614
 
935
2615
 
936
- def _extract_location_for_recruiting_job_question(question: str) -> str | None:
937
- if not _is_recruiting_job_question(question):
938
- return None
939
- text = re.sub(r"(查询|查看|看看|列出|给我|帮我查|当前|目前|现在)", "", question)
940
- text = re.sub(r"(所有|全部|每个|各个)", "", text)
941
- patterns = [
942
- r"(?P<location>[\u4e00-\u9fff]{2,12}?)(?:有)?(?:多少|几|数量|总数).*?(?:在招|招聘)(?:岗位|职位)",
943
- r"(?P<location>[\u4e00-\u9fff]{2,12}?)(?:的)?(?:在招|招聘)(?:岗位|职位)",
944
- r"(?P<location>[\u4e00-\u9fff]{2,12}?)(?:有|有哪些|有什么).*?(?:在招|招聘)(?:岗位|职位)",
945
- r"(?P<location>[\u4e00-\u9fff]{2,12}?)(?:的)?(?:岗位|职位)",
946
- r"(?:在|位于)(?P<location>[\u4e00-\u9fff]{2,12}?)(?:的)?(?:岗位|职位)",
947
- ]
948
- for pattern in patterns:
949
- match = re.search(pattern, text)
950
- if match is not None:
951
- return _clean_location_candidate(match.group("location"), require_location_signal=True)
952
- return None
953
-
954
-
955
- def _is_recruiting_job_question(question: str) -> bool:
956
- return bool(re.search(r"(岗位|职位|招聘|在招)", question))
957
-
958
-
959
- def _is_work_order_detail_query(question: str) -> bool:
960
- return bool(
961
- "品牌" in question
962
- and re.search(r"(报名|工单)", question)
963
- and re.search(r"(候选人状态|候选人|面试成功)", question)
964
- and re.search(r"(姓名|名字|岗位|职位|项目)", question)
965
- )
966
-
967
-
968
2616
  def _is_business_query(question: str) -> bool:
969
2617
  return bool(
970
2618
  re.search(
971
- r"(报名|工单|候选人|入职|上岗|在招|招聘|岗位|职位|门店|剩余|转化|漏斗|匹配|统计|多少|几|数量|总数|人数|数)",
2619
+ r"(查询|查看|看看|搜索|统计|多少|几|哪些|有哪些|列表|明细|清单|展示|返回|输出|分析|报告|count|sum|avg|min|max)",
972
2620
  question,
2621
+ flags=re.IGNORECASE,
973
2622
  )
974
2623
  )
975
2624
 
976
2625
 
977
- def _is_single_entity_lookup_sql(sql: str) -> bool:
978
- normalized = re.sub(r"\s+", " ", sql.strip().lower())
979
- table_aliases = _sql_table_aliases(normalized)
980
- referenced_tables = set(table_aliases.values())
981
- entity_tables = {"brand", "sponge_project", "city", "province", "region", "store"}
982
- if len(referenced_tables) != 1 or not referenced_tables <= entity_tables:
983
- return False
984
- if " limit " not in f" {normalized} ":
985
- return False
986
- return bool(re.search(r"\b(?:id|name|job_name)\b", normalized))
987
-
988
-
989
- _ENTITY_NAME_CHARS = r"[\u4e00-\u9fffA-Za-z0-9()()·_\-]{1,40}"
990
- _ENTITY_SEPARATOR = r"[\s::,,、;;]+"
991
- _FIELD_LABEL_BOUNDARY = (
992
- r"(?:\s+(?=门店|品牌|项目|岗位|职位|城市|区域|有哪些|有什么|多少|包括|返回|状态)"
993
- r"|(?=在(?!招)|位于|有哪些|有什么|多少|包括|返回|状态|岗位|职位|门店|品牌|项目|$|[,,。;;??]))"
994
- )
995
- _DIRECT_MUNICIPALITIES = ("上海", "北京", "天津", "重庆")
996
-
997
-
998
- def _extract_brand_filter(question: str) -> tuple[str, str] | None:
999
- contains_patterns = [
1000
- r"品牌(?:名称)?(?:包含|含有|模糊匹配|like)(?P<brand>.+?)(?:,|,|。|;|;|的品牌|品牌信息|包括|返回|$)",
1001
- r"(?:name|名称)\s+LIKE\s+[\"'“”‘’%]*(?P<brand>[\u4e00-\u9fffA-Za-z0-9()()·_\-]{1,40})",
1002
- ]
1003
- for pattern in contains_patterns:
1004
- match = re.search(pattern, question, flags=re.IGNORECASE)
1005
- if match is None:
1006
- continue
1007
- brand = _clean_brand_candidate(match.group("brand"))
1008
- if brand is not None:
1009
- return "contains", brand
1010
-
1011
- exact_patterns = [
1012
- rf"品牌(?:名称)?(?:为|是|=|等于|:|:)(?:{_ENTITY_SEPARATOR})?(?P<brand>{_ENTITY_NAME_CHARS})",
1013
- rf"品牌{_ENTITY_SEPARATOR}(?P<brand>{_ENTITY_NAME_CHARS})",
1014
- rf"品牌(?P<brand>{_ENTITY_NAME_CHARS}?)(?={_FIELD_LABEL_BOUNDARY})",
1015
- rf"(?P<brand>{_ENTITY_NAME_CHARS})(?:{_ENTITY_SEPARATOR})?品牌(?:下|的)?",
1016
- ]
1017
- for pattern in exact_patterns:
1018
- match = re.search(pattern, question)
1019
- if match is None:
1020
- continue
1021
- brand = _clean_brand_candidate(match.group("brand"))
1022
- if brand is not None:
1023
- return "exact", brand
1024
- return None
1025
-
1026
-
1027
- def _extract_brand_name(question: str) -> str | None:
1028
- brand_filter = _extract_brand_filter(question)
1029
- return brand_filter[1] if brand_filter is not None else None
1030
-
1031
-
1032
- def _extract_project_name(question: str) -> str | None:
1033
- patterns = [
1034
- r"项目(?:名称)?(?:包含|含有|模糊匹配|like)(?P<name>.+?)(?:,|,|。|;|;|项目信息|包括|返回|$)",
1035
- rf"项目(?:名称)?(?:为|是|=|等于|:|:)(?:{_ENTITY_SEPARATOR})?(?P<name>{_ENTITY_NAME_CHARS})",
1036
- rf"项目{_ENTITY_SEPARATOR}(?P<name>{_ENTITY_NAME_CHARS})",
1037
- rf"(?P<name>{_ENTITY_NAME_CHARS})(?:{_ENTITY_SEPARATOR})?项目(?:下|的)?",
1038
- rf"项目(?P<name>{_ENTITY_NAME_CHARS})(?={_FIELD_LABEL_BOUNDARY})",
1039
- ]
1040
- for pattern in patterns:
1041
- match = re.search(pattern, question, flags=re.IGNORECASE)
1042
- if match is None:
1043
- continue
1044
- project = _clean_project_candidate(match.group("name"))
1045
- if project is not None:
1046
- return project
1047
- return None
1048
-
1049
-
1050
- def _clean_project_candidate(value: str) -> str | None:
1051
- return _clean_entity_candidate(
1052
- value,
1053
- extra_forbidden=("品牌", "岗位", "职位", "报名"),
1054
- suffix_pattern=r"((?:在|位于)[\u4e00-\u9fff]{1,20}(?:市|省|区|县)?.*$|(?:的)?(?:岗位|职位).*$|的项目信息|项目信息|的项目|项目|的|下)$",
1055
- reject_location_like=True,
1056
- )
1057
-
1058
-
1059
- def _extract_job_name_filter(question: str) -> str | None:
1060
- patterns = [
1061
- r"(?:岗位|职位)(?:名称)?(?:包含|含有|模糊匹配|like)(?P<name>.+?)(?:,|,|。|;|;|岗位信息|职位信息|包括|返回|$)",
1062
- rf"(?:岗位|职位)(?:名称)?(?:为|是|=|等于|:|:)(?:{_ENTITY_SEPARATOR})?(?P<name>{_ENTITY_NAME_CHARS})",
1063
- rf"(?:招聘|在招|招|急招)(?:{_ENTITY_SEPARATOR})?(?P<name>{_ENTITY_NAME_CHARS})(?:岗位|职位)",
1064
- ]
1065
- for pattern in patterns:
1066
- match = re.search(pattern, question, flags=re.IGNORECASE)
1067
- if match is None:
1068
- continue
1069
- job_name = _clean_entity_candidate(match.group("name"), extra_forbidden=("品牌", "项目", "门店", "报名"))
1070
- if job_name is not None:
1071
- return job_name
1072
- return None
1073
-
1074
-
1075
- def _extract_store_name_filter(question: str) -> str | None:
1076
- patterns = [
1077
- r"门店(?:名称)?(?:包含|含有|模糊匹配|like)(?P<name>.+?)(?:,|,|。|;|;|门店信息|包括|返回|$)",
1078
- rf"门店(?:名称)?(?:为|是|=|等于|:|:)(?:{_ENTITY_SEPARATOR})?(?P<name>{_ENTITY_NAME_CHARS})",
1079
- rf"(?<!所属)门店(?P<name>{_ENTITY_NAME_CHARS})(?=[,,。;;??\s]|有哪些|有什么|多少|岗位|职位|在招|招聘|$)",
1080
- rf"(?P<name>{_ENTITY_NAME_CHARS})(?:{_ENTITY_SEPARATOR})?的门店",
1081
- ]
1082
- for pattern in patterns:
1083
- match = re.search(pattern, question, flags=re.IGNORECASE)
1084
- if match is None:
1085
- continue
1086
- store_name = _clean_entity_candidate(
1087
- match.group("name"),
1088
- extra_forbidden=("品牌", "项目", "岗位", "职位", "报名", "薪资", "学历", "学历要求", "年龄要求"),
1089
- suffix_pattern=r"((?:的)?(?:岗位|职位).*$|门店信息|的门店|门店|的|下)$",
1090
- reject_location_like=True,
1091
- )
1092
- if store_name is not None:
1093
- return store_name
1094
- return None
1095
-
1096
-
1097
- def _clean_brand_candidate(value: str) -> str | None:
1098
- return _clean_entity_candidate(
1099
- value,
1100
- extra_forbidden=("岗位", "候选人", "报名", "入职", "在招", "招聘", "数量", "多少"),
1101
- suffix_pattern=r"((?:的)?(?:岗位|职位).*$|的品牌信息|品牌信息|的品牌|品牌|的|下)$",
1102
- )
1103
-
1104
-
1105
- def _clean_entity_candidate(
1106
- value: str,
1107
- *,
1108
- extra_forbidden: tuple[str, ...] = (),
1109
- suffix_pattern: str = r"(的|下)$",
1110
- reject_location_like: bool = False,
1111
- ) -> str | None:
1112
- brand = re.sub(r"\s+", "", value)
1113
- brand = brand.strip("\"'“”‘’%::,,、;;")
1114
- brand = re.sub(r"^(名称为|名称是|为|是|=|等于)", "", brand)
1115
- brand = re.sub(suffix_pattern, "", brand)
1116
- brand = brand.strip("\"'“”‘’%::,,、;;")
1117
- forbidden = (
1118
- "查询",
1119
- "查看",
1120
- "给我",
1121
- "帮我",
1122
- "根据",
1123
- "roll-core",
1124
- "信息",
1125
- "数据",
1126
- "列表",
1127
- "明细",
1128
- "情况",
1129
- "哪些",
1130
- "所有",
1131
- "全部",
1132
- "我能看到",
1133
- ) + extra_forbidden
1134
- if reject_location_like and _is_location_like_entity_name(brand):
1135
- return None
1136
- if 2 <= len(brand) <= 40 and not any(term in brand for term in forbidden):
1137
- return brand
1138
- return None
1139
-
1140
-
1141
- def _is_location_like_entity_name(value: str) -> bool:
1142
- return bool(
1143
- re.fullmatch(r"在[\u4e00-\u9fff]{1,20}(?:市|省|区|县)?", value)
1144
- or re.fullmatch(r"位于[\u4e00-\u9fff]{1,20}(?:市|省|区|县)?", value)
1145
- or value.startswith(("在", "位于"))
1146
- )
1147
-
1148
-
1149
- def _sql_references_brand_table(sql: str) -> bool:
1150
- return _sql_references_table(sql, "brand")
1151
-
1152
-
1153
- def _sql_contains_brand_name_filter(sql: str, brand_name: str) -> bool:
1154
- return _sql_contains_name_filter(sql, brand_name)
1155
-
1156
-
1157
- def _sql_references_table(sql: str, table_name: str) -> bool:
1158
- return table_name in _sql_table_aliases(sql.lower()).values()
1159
-
1160
-
1161
- def _sql_contains_name_filter(sql: str, name: str) -> bool:
1162
- normalized = sql.lower()
1163
- escaped = re.escape(_escape_sql_string(name).lower())
1164
- return bool(
1165
- re.search(rf"\b[a-zA-Z_][a-zA-Z0-9_]*\.name\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized)
1166
- or re.search(rf"\bname\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized)
1167
- )
1168
-
1169
-
1170
- def _sql_contains_project_id_filter(sql: str) -> bool:
1171
- normalized = sql.lower()
1172
- table_aliases = _sql_table_aliases(normalized)
1173
- project_aliases = [alias for alias, table in table_aliases.items() if table == "sponge_project"]
1174
- if not project_aliases:
1175
- return False
1176
- alias_pattern = "|".join(re.escape(alias) for alias in sorted(set(project_aliases), key=len, reverse=True))
1177
- return bool(re.search(rf"\b(?:{alias_pattern})\.id\s*=\s*\d+\b", normalized))
1178
-
1179
-
1180
- def _sql_contains_job_name_filter(sql: str, job_name: str) -> bool:
1181
- normalized = sql.lower()
1182
- escaped = re.escape(_escape_sql_string(job_name).lower())
1183
- return bool(
1184
- re.search(rf"\bjbi\.job_name\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized)
1185
- or re.search(rf"\bjob_name\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized)
1186
- )
1187
-
1188
-
1189
- def _sql_contains_store_name_filter(sql: str, store_name: str) -> bool:
1190
- normalized = sql.lower()
1191
- escaped = re.escape(_escape_sql_string(store_name).lower())
1192
- table_aliases = _sql_table_aliases(normalized)
1193
- store_aliases = [alias for alias, table in table_aliases.items() if table == "store"]
1194
- if store_aliases:
1195
- alias_pattern = "|".join(re.escape(alias) for alias in sorted(set(store_aliases), key=len, reverse=True))
1196
- if re.search(rf"\b(?:{alias_pattern})\.name\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized):
1197
- return True
1198
- return bool(re.search(rf"\bstore_name\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized))
1199
-
1200
-
1201
- def is_recruiting_match_effect_query(question: str) -> bool:
1202
- return bool(
1203
- "岗位" in question
1204
- and re.search(r"(候选人|报名|入职|上岗)", question)
1205
- and re.search(r"(匹配效果|报名多|入职少|在招多|报名少|候选人多|岗位少)", question)
1206
- and re.search(r"(品牌|项目|城市)", question)
1207
- )
1208
-
1209
-
1210
- def _clean_location_candidate(candidate: str, *, require_location_signal: bool = False) -> str | None:
1211
- cleaned = re.sub(r"[,,。;;??\s]", "", candidate)
1212
- cleaned = re.sub(r"(有哪些|有什么|所有|全部|每个|各个|多少|数量|列表|明细|信息|数据|情况|都|的|有)$", "", cleaned)
1213
- if not 2 <= len(cleaned) <= 12:
1214
- return None
1215
- if re.search(r"[\u4e00-\u9fff]", cleaned) is None:
1216
- return None
1217
- if re.search(r"(岗位|职位|招聘|在招|品牌|项目|查询|查看|当前|目前|现在)", cleaned):
1218
- return None
1219
- if _is_generic_recruiting_term(cleaned):
1220
- return None
1221
- if require_location_signal and not _has_location_signal(cleaned):
1222
- return None
1223
- return cleaned
1224
-
1225
-
1226
- def _has_location_signal(candidate: str) -> bool:
1227
- if re.search(r"(省|市|区|县|镇|乡|街道|路|街|大道|广场|商圈|附近|地铁|站|商场|门店)$", candidate):
1228
- return True
1229
- return 2 <= len(candidate) <= 4 and not _is_generic_recruiting_term(candidate)
1230
-
1231
-
1232
- def _is_generic_recruiting_term(candidate: str) -> bool:
1233
- generic_terms = {
1234
- "兼职",
1235
- "全职",
1236
- "小时工",
1237
- "暑假工",
1238
- "寒假工",
1239
- "长期工",
1240
- "短期工",
1241
- "服务员",
1242
- "店员",
1243
- "骑手",
1244
- "厨师",
1245
- "收银员",
1246
- "营业员",
1247
- "理货员",
1248
- "分拣",
1249
- "补货",
1250
- "打包",
1251
- "餐厅",
1252
- "门店",
1253
- "品牌",
1254
- "项目",
1255
- "高薪",
1256
- "急招",
1257
- "最新",
1258
- "全部",
1259
- "所有",
1260
- "热门",
1261
- "附近",
1262
- "本周",
1263
- "今天",
1264
- "明天",
1265
- }
1266
- return candidate in generic_terms
1267
-
1268
-
1269
2626
  def _escape_sql_string(value: str) -> str:
1270
2627
  return value.replace("\\", "\\\\").replace("'", "''")
1271
2628
 
@@ -1279,7 +2636,7 @@ def _enforce_chinese_select_aliases(sql: str) -> None:
1279
2636
  if not cleaned:
1280
2637
  continue
1281
2638
  alias_match = re.search(
1282
- r"\s+as\s+(`[^`]+`|\"[^\"]+\"|'[^']+'|[\u4e00-\u9fff][\w\u4e00-\u9fff]*)\s*$",
2639
+ _SELECT_AS_ALIAS_PATTERN,
1283
2640
  cleaned,
1284
2641
  flags=re.IGNORECASE,
1285
2642
  )
@@ -1302,7 +2659,7 @@ def _enforce_no_null_placeholder_select_items(sql: str) -> None:
1302
2659
 
1303
2660
  def _select_expression_without_alias(item: str) -> str:
1304
2661
  return re.sub(
1305
- r"\s+as\s+(`[^`]+`|\"[^\"]+\"|'[^']+'|[\u4e00-\u9fff][\w\u4e00-\u9fff]*)\s*$",
2662
+ _SELECT_AS_ALIAS_PATTERN,
1306
2663
  "",
1307
2664
  item.strip(),
1308
2665
  flags=re.IGNORECASE,