@roll-agent/octopus-agent 0.1.0 → 0.1.2

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,7 +5,12 @@ import re
5
5
  from dataclasses import dataclass
6
6
  from typing import Any, Callable
7
7
 
8
- from .prompt_builder import NL2SQL_SYSTEM_PROMPT, build_nl2sql_prompt, build_sql_repair_prompt
8
+ from .prompt_builder import (
9
+ NL2SQL_SYSTEM_PROMPT,
10
+ build_nl2sql_prompt,
11
+ build_sql_expert_review_prompt,
12
+ build_sql_repair_prompt,
13
+ )
9
14
  from .schema_context_retriever import SchemaContext
10
15
  from .sql_generator import GeneratedSql, SqlGenerationError, contains_forbidden_dml_keyword
11
16
 
@@ -54,6 +59,30 @@ class SamplingSqlGenerator:
54
59
  )
55
60
  return _parse_generated_sql(self.sampler(NL2SQL_SYSTEM_PROMPT, prompt))
56
61
 
62
+ def review_sql(
63
+ self,
64
+ *,
65
+ question: str,
66
+ sql: str,
67
+ schema_context: SchemaContext,
68
+ ) -> "SqlExpertReview":
69
+ prompt = build_sql_expert_review_prompt(
70
+ question=question,
71
+ sql=sql,
72
+ schema_context=schema_context,
73
+ default_limit=self.default_limit,
74
+ max_limit=self.max_limit,
75
+ confirmed_query_plan=self.confirmed_query_plan,
76
+ )
77
+ return _parse_sql_expert_review(self.sampler(NL2SQL_SYSTEM_PROMPT, prompt))
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class SqlExpertReview:
82
+ executable: bool
83
+ issues: list[dict[str, str]]
84
+ repaired_sql: str | None = None
85
+
57
86
 
58
87
  def _parse_generated_sql(text: str) -> GeneratedSql:
59
88
  try:
@@ -101,6 +130,31 @@ def _parse_generated_sql(text: str) -> GeneratedSql:
101
130
  )
102
131
 
103
132
 
133
+ def _parse_sql_expert_review(text: str) -> SqlExpertReview:
134
+ payload = _load_json_object(text)
135
+ executable = bool(payload.get("executable", False))
136
+ issues_payload = payload.get("issues")
137
+ issues: list[dict[str, str]] = []
138
+ if isinstance(issues_payload, list):
139
+ for item in issues_payload:
140
+ if not isinstance(item, dict):
141
+ continue
142
+ issues.append(
143
+ {
144
+ "code": str(item.get("code") or ""),
145
+ "message": str(item.get("message") or ""),
146
+ "repairHint": str(item.get("repairHint") or ""),
147
+ }
148
+ )
149
+ repaired_sql = payload.get("repairedSql")
150
+ if isinstance(repaired_sql, str) and repaired_sql.strip():
151
+ repaired_sql = repaired_sql.strip()
152
+ _require_select_sql(repaired_sql)
153
+ else:
154
+ repaired_sql = None
155
+ return SqlExpertReview(executable=executable, issues=issues, repaired_sql=repaired_sql)
156
+
157
+
104
158
  def _load_json_object(text: str) -> dict[str, Any]:
105
159
  cleaned = text.strip()
106
160
  if cleaned.startswith("```"):
@@ -469,7 +469,7 @@ def _query_result_payload(query_result: Any, *, include_sql: bool = False, base_
469
469
  }
470
470
  query_plan = getattr(query_result, "query_plan", None)
471
471
  if isinstance(query_plan, dict):
472
- payload["queryPlan"] = query_plan
472
+ payload["queryPlan"] = _public_query_plan(query_plan)
473
473
  execution_steps = getattr(query_result, "execution_steps", None)
474
474
  if isinstance(execution_steps, list):
475
475
  payload["executionSteps"] = execution_steps
@@ -485,6 +485,14 @@ def _query_result_payload(query_result: Any, *, include_sql: bool = False, base_
485
485
  return _jsonable(payload)
486
486
 
487
487
 
488
+ def _public_query_plan(query_plan: dict[str, Any]) -> Json:
489
+ return {
490
+ key: value
491
+ for key, value in query_plan.items()
492
+ if not str(key).startswith("_")
493
+ }
494
+
495
+
488
496
  def _user_visible_answer(query_result: Any, *, base_question: str | None = None) -> str:
489
497
  answer = str(getattr(query_result, "answer", ""))
490
498
  question = base_question or ""
@@ -17,7 +17,8 @@ INSTRUCTION_STAGE_PREFIX: dict[InstructionStage, str] = {
17
17
  }
18
18
 
19
19
 
20
- NL2SQL_SYSTEM_PROMPT = """你是丸子Agent(Octopus Agent)的海绵数据 SQL 生成器。
20
+ NL2SQL_SYSTEM_PROMPT = """你是丸子Agent(Octopus Agent)的海绵数据 SQL 专家,一名专业 MySQL SQL 专家。
21
+ 你必须以 SQL 专家的标准生成 SQL:输出前逐句自检,确保 SQL 整体可以直接执行,没有任何 SQL 语法错误,也没有 SQL 写法问题(如括号/引号不闭合、SELECT 与 GROUP BY 不一致、聚合函数误用、JOIN 缺少等值键、别名冲突、字段/表拼写错误等)。
21
22
  业务规则的唯一来源是 payload 中的 mandatoryAgentInstructions 与 schemaContext.policy;必须逐条遵守,不得使用训练记忆中的业务口径替代 schema 要求。
22
23
  生成 SQL 前必须严格按 schemaContext.readOrder 顺序阅读 schemaContext 各段内容,再组合 SELECT、JOIN、WHERE、LIMIT。
23
24
  只输出 JSON,结构见 outputSchema。返回 JSON 中的 sql 字段必须直接以 SELECT 开头;不要为了状态枚举单独返回 clarification。用户明确提出状态筛选时把状态条件写入 WHERE;用户未提出状态筛选或 schemaContext 缺少枚举时不要追问,首次查询保持宽泛,并把相关状态字段及中文含义加入 SELECT 结果。"""
@@ -76,6 +77,46 @@ def build_sql_repair_prompt(
76
77
  )
77
78
 
78
79
 
80
+ def build_sql_expert_review_prompt(
81
+ *,
82
+ question: str,
83
+ sql: str,
84
+ schema_context: SchemaContext,
85
+ default_limit: int,
86
+ max_limit: int,
87
+ confirmed_query_plan: dict[str, Any] | None = None,
88
+ ) -> str:
89
+ payload = _base_sql_prompt_payload(
90
+ question=question,
91
+ schema_context=schema_context,
92
+ default_limit=default_limit,
93
+ max_limit=max_limit,
94
+ confirmed_query_plan=confirmed_query_plan,
95
+ task="review_sql_executability",
96
+ original_sql=sql,
97
+ )
98
+ payload["reviewInstruction"] = (
99
+ "你是专业 MySQL SQL 专家,只检查 SQL 写法是否整体可执行,不改变业务口径。"
100
+ "重点检查:聚合函数是否误写在 WHERE/JOIN ON;GROUP BY/HAVING 是否合法;"
101
+ "子查询和派生表别名是否完整;外层引用的派生表字段是否存在;"
102
+ "SELECT 中非聚合字段与 GROUP BY 是否兼容;MySQL 低版本不支持 WITH。"
103
+ "如果 SQL 可执行,返回 executable=true 且 repairedSql 为空;"
104
+ "如果不可执行,返回 executable=false、issues,并在不改变业务口径和 schema 字段的前提下给出 repairedSql。"
105
+ )
106
+ payload["reviewOutputSchema"] = {
107
+ "executable": True,
108
+ "issues": [{"code": "ISSUE_CODE", "message": "中文问题说明", "repairHint": "中文修复建议"}],
109
+ "repairedSql": "SELECT ... LIMIT 50 或空字符串",
110
+ }
111
+ compact = confirmed_query_plan is not None
112
+ return json.dumps(
113
+ payload,
114
+ ensure_ascii=False,
115
+ indent=None if compact else 2,
116
+ separators=(",", ":") if compact else (", ", ": "),
117
+ )
118
+
119
+
79
120
  def _base_sql_prompt_payload(
80
121
  *,
81
122
  question: str,
@@ -99,8 +140,13 @@ def _base_sql_prompt_payload(
99
140
  "schemaContext 未提供对应口径时,不要凭训练记忆或字段名猜测 SQL。"
100
141
  "涉及时间范围时,如果命中的 schemaContext.concepts 或 metrics 提供 timeRangeField,"
101
142
  "必须使用该字段生成 WHERE 时间过滤,不要改用关联维表的同名时间字段。"
143
+ "JOIN ... ON 只能写 schemaContext.relations / joins / exampleJoinPatterns 中提供的等值关联键;"
144
+ "禁止凭字段名相似或训练记忆新增 schema 未定义 JOIN。"
102
145
  "涉及“最多/最高/最大/排名/排行”等聚合极值诉求时,必须优先按聚合指标倒序排序,"
103
146
  "再按时间等次要维度排序。"
147
+ "你必须作为专业 MySQL SQL 专家自检 SQL 整体可执行性:聚合函数不能写在 WHERE/JOIN ON,"
148
+ "聚合筛选必须放 HAVING 或先在子查询聚合后外层过滤;派生表字段和别名必须真实存在;"
149
+ "MySQL 低版本不支持 WITH。"
104
150
  "不要把查询动作、状态变化、时间词或范围词当作名称筛选;"
105
151
  "只有用户明确说“名称包含/岗位名称是/叫做/简称为”时,才允许对 name/title/job_name 写 LIKE。"
106
152
  if not compact
@@ -112,8 +158,13 @@ def _base_sql_prompt_payload(
112
158
  "schemaContext 未提供对应口径时,不要凭训练记忆或字段名猜测 SQL。"
113
159
  "涉及时间范围时,如果命中的 schemaContext.concepts 或 metrics 提供 timeRangeField,"
114
160
  "必须使用该字段生成 WHERE 时间过滤,不要改用关联维表的同名时间字段。"
161
+ "JOIN ... ON 只能写 schemaContext.relations / joins / exampleJoinPatterns 中提供的等值关联键;"
162
+ "禁止凭字段名相似或训练记忆新增 schema 未定义 JOIN。"
115
163
  "涉及“最多/最高/最大/排名/排行”等聚合极值诉求时,必须优先按聚合指标倒序排序,"
116
164
  "再按时间等次要维度排序。"
165
+ "你必须作为专业 MySQL SQL 专家自检 SQL 整体可执行性:聚合函数不能写在 WHERE/JOIN ON,"
166
+ "聚合筛选必须放 HAVING 或先在子查询聚合后外层过滤;派生表字段和别名必须真实存在;"
167
+ "MySQL 低版本不支持 WITH。"
117
168
  "JOIN ... ON 只能写 schema relations / joins 中的等值关联键(如 a.id = b.x_id);"
118
169
  "confirmedQueryPlan.filters 里的 LIKE/IN 筛选必须写在 WHERE,禁止写在 JOIN ON;"
119
170
  "同一 logicalGroup 且 logicalOperator=OR 的筛选必须用 OR 连接,并用括号包裹;"
@@ -125,10 +176,13 @@ def _base_sql_prompt_payload(
125
176
  "schemaContext": _schema_context_payload(schema_context, compact=compact),
126
177
  "outputSchema": _sql_output_schema(default_limit, question),
127
178
  }
128
- if task == "repair_sql":
179
+ if task:
129
180
  payload["task"] = task
181
+ if task == "repair_sql":
130
182
  payload["originalSql"] = original_sql or ""
131
183
  payload["validationError"] = validation_error or {}
184
+ elif task == "review_sql_executability":
185
+ payload["sqlToReview"] = original_sql or ""
132
186
  if confirmed_query_plan:
133
187
  payload["confirmedQueryPlan"] = _compact_query_plan_for_sql(confirmed_query_plan)
134
188
  return payload
@@ -198,13 +252,16 @@ def _compact_query_plan_for_sql(query_plan: dict[str, Any]) -> dict[str, Any]:
198
252
  ]
199
253
  if entities:
200
254
  compact["entities"] = entities
255
+ plan_filters = query_plan.get("_sqlFilters")
256
+ if not isinstance(plan_filters, list):
257
+ plan_filters = query_plan.get("filters", [])
201
258
  filters = [
202
259
  {
203
260
  key: item[key]
204
261
  for key in ("field", "operator", "value", "description", "source", "logicalGroup", "logicalOperator")
205
262
  if key in item
206
263
  }
207
- for item in query_plan.get("filters", [])
264
+ for item in plan_filters
208
265
  if isinstance(item, dict)
209
266
  ]
210
267
  if filters:
@@ -132,10 +132,15 @@ class QuestionAnalysisError(Exception):
132
132
  """Raised when question analysis fails."""
133
133
 
134
134
 
135
+ SAMPLING_MAX_RETRIES = 2
136
+
137
+
135
138
  def analyze_question(
136
139
  question: str,
137
140
  sampler: Sampler,
138
141
  agent_instructions: list[dict[str, Any]] | None = None,
142
+ *,
143
+ max_sampling_retries: int = SAMPLING_MAX_RETRIES,
139
144
  ) -> QuestionAnalysis:
140
145
  """Use LLM to analyze user question and extract structured intent + query targets.
141
146
 
@@ -152,12 +157,18 @@ def analyze_question(
152
157
  """
153
158
  prompt = build_question_analysis_prompt(question, agent_instructions)
154
159
 
155
- try:
156
- raw_response = sampler(QUESTION_ANALYSIS_SYSTEM_PROMPT, prompt)
157
- except Exception as exc:
158
- raise QuestionAnalysisError(f"LLM 调用失败:{type(exc).__name__}: {exc}") from exc
159
-
160
- payload = _parse_analysis_response(raw_response)
160
+ raw_response: str | None = None
161
+ for attempt in range(max_sampling_retries + 1):
162
+ try:
163
+ raw_response = sampler(QUESTION_ANALYSIS_SYSTEM_PROMPT, prompt)
164
+ break
165
+ except Exception as exc:
166
+ if attempt >= max_sampling_retries:
167
+ raise QuestionAnalysisError(
168
+ f"LLM 调用失败(已重试 {max_sampling_retries} 次):{type(exc).__name__}: {exc}"
169
+ ) from exc
170
+
171
+ payload = _parse_analysis_response(raw_response or "")
161
172
  return _build_analysis(payload, question)
162
173
 
163
174
 
@@ -49,6 +49,9 @@ class SchemaValidationIssue:
49
49
  class LowQualitySqlIssue:
50
50
  code: str
51
51
  message: str
52
+ missing_items: tuple[str, ...] = ()
53
+ current_problem: str = ""
54
+ repair_hint: str = ""
52
55
 
53
56
 
54
57
  _SELECT_AS_ALIAS_PATTERN = (
@@ -86,6 +89,7 @@ def enforce_sql_rules(sql: str, *, max_limit: int) -> None:
86
89
  raise SqlGenerationError("SQL must not contain scope placeholders")
87
90
  _enforce_text_filters_use_like(sql)
88
91
  _enforce_join_on_uses_equality_keys(sql)
92
+ _enforce_no_aggregate_functions_in_row_filters(sql)
89
93
  _enforce_chinese_select_aliases(sql)
90
94
  _enforce_no_null_placeholder_select_items(sql)
91
95
  _enforce_no_tautological_join_conditions(sql)
@@ -724,6 +728,41 @@ def _enforce_join_on_uses_equality_keys(sql: str) -> None:
724
728
  raise SqlGenerationError(violations[0])
725
729
 
726
730
 
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"
749
+ )
750
+
751
+
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]
764
+
765
+
727
766
  def _select_item_has_chinese_alias(item: str) -> bool:
728
767
  alias = _extract_select_alias(item)
729
768
  return alias is not None and re.search(r"[\u4e00-\u9fff]", alias) is not None
@@ -877,7 +916,18 @@ def low_quality_sql_issue_for_question(question: str, sql: str, *, schema: dict[
877
916
  table = str(table_name or "").strip().lower()
878
917
  if table and table not in normalized:
879
918
  message = str(checks.get("message") or f"SQL 必须引用 schema 要求的表:{table_name}")
880
- return LowQualitySqlIssue(code="SCHEMA_SQL_QUALITY", message=message)
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
+ )
881
931
  aggregate_issue = _aggregate_sql_quality_issue(question, sql)
882
932
  if aggregate_issue is not None:
883
933
  return aggregate_issue
@@ -901,6 +951,9 @@ def _concept_time_range_field_issue(
901
951
  f"用户问题包含时间范围,且 schema concept 指定时间字段为 {qualified};"
902
952
  "SQL 必须使用该字段过滤时间范围,不要改用关联维表的同名时间字段。"
903
953
  ),
954
+ missing_items=(f"时间范围字段 {qualified}",),
955
+ current_problem=f"当前 SQL 没有使用 {qualified} 过滤用户要求的时间范围。",
956
+ repair_hint=f"在 WHERE 中使用 {qualified} 补充时间范围过滤。",
904
957
  )
905
958
  unexpected = _unexpected_time_filter_fields(sql, expected_field=qualified)
906
959
  if unexpected:
@@ -910,22 +963,62 @@ def _concept_time_range_field_issue(
910
963
  f"用户问题包含时间范围,且 schema concept 指定时间字段为 {qualified};"
911
964
  f"SQL 不应同时使用 {'、'.join(unexpected)} 过滤时间范围。"
912
965
  ),
966
+ missing_items=(f"唯一时间范围字段 {qualified}",),
967
+ current_problem=f"当前 SQL 还使用了 {'、'.join(unexpected)} 过滤时间范围,可能把口径切到关联表时间。",
968
+ repair_hint=f"删除关联表时间过滤,只保留 {qualified} 的时间范围条件。",
913
969
  )
914
970
  return None
915
971
 
916
972
 
973
+ _TIME_RANGE_DATE_LITERAL = r"'20\d{2}-\d{1,2}-\d{1,2}(?:\s+\d{1,2}:\d{2}:\d{2})?'"
974
+
975
+
917
976
  def _sql_has_time_filter_for_range(sql: str, time_range: TimeRange) -> bool:
918
- where_index = _find_top_level_keyword(sql, "where", start=0)
919
- if where_index is None:
920
- return False
921
- clause_start = where_index + len("where")
922
- clause_end = _where_filter_insert_pos(sql[clause_start:])
923
- if clause_end < len(sql[clause_start:]):
924
- clause_end += clause_start
925
- else:
926
- clause_end = len(sql)
927
- for predicate in _split_top_level_and_predicates(sql[clause_start:clause_end].strip()):
928
- if _is_time_range_predicate(predicate, sql=sql, time_range=time_range):
977
+ if _scope_has_time_filter_for_range(sql, time_range):
978
+ return True
979
+ for subquery in _sql_derived_subqueries(sql).values():
980
+ if _sql_has_time_filter_for_range(subquery, time_range):
981
+ return True
982
+ return False
983
+
984
+
985
+ def _mask_derived_subqueries(sql: str) -> str:
986
+ spans = _sql_derived_subquery_spans(sql)
987
+ if not spans:
988
+ return sql
989
+ chars = list(sql)
990
+ for start, end in spans:
991
+ for index in range(start, min(end, len(chars))):
992
+ chars[index] = " "
993
+ return "".join(chars)
994
+
995
+
996
+ def _scope_has_time_filter_for_range(sql: str, time_range: TimeRange) -> bool:
997
+ """Return True if the designated time field is compared to a date literal in this
998
+ query scope.
999
+
1000
+ Nested derived subqueries are masked out here and evaluated separately by
1001
+ ``_sql_has_time_filter_for_range`` so alias resolution stays scoped. The check
1002
+ scans the whole scope (not just the top-level WHERE) so the field can satisfy the
1003
+ requirement whether it is used in WHERE or inside a conditional aggregate such as
1004
+ ``SUM(CASE WHEN sign_up_time >= ... THEN 1 ELSE 0 END)``, which some schema concepts
1005
+ mandate for period metrics.
1006
+ """
1007
+ scope_text = _mask_derived_subqueries(sql)
1008
+ op = r"(?:>=|<=|>|<|=)"
1009
+ date = _TIME_RANGE_DATE_LITERAL
1010
+ field_patterns: list[str] = [_time_range_sql_field_pattern(scope_text, time_range)]
1011
+ base_tables = set(_sql_top_level_table_aliases(scope_text).values())
1012
+ if not time_range.table_name or base_tables == {time_range.table_name.lower()}:
1013
+ # Single base table in scope: an unqualified column reference is unambiguous.
1014
+ field_patterns.append(rf"(?<![.\w]){re.escape(time_range.field.lower())}\b")
1015
+ for field_pattern in field_patterns:
1016
+ checks = (
1017
+ rf"{field_pattern}\s*{op}\s*{date}",
1018
+ rf"{date}\s*{op}\s*{field_pattern}",
1019
+ rf"{field_pattern}\s+between\s+{date}\s+and\s+{date}",
1020
+ )
1021
+ if any(re.search(pattern, scope_text, flags=re.IGNORECASE) for pattern in checks):
929
1022
  return True
930
1023
  return False
931
1024
 
@@ -972,9 +1065,23 @@ def _aggregate_sql_quality_issue(question: str, sql: str) -> LowQualitySqlIssue
972
1065
  return LowQualitySqlIssue(
973
1066
  code="AGGREGATE_SQL_REQUIRED",
974
1067
  message="用户问题要求统计数量/最多值及对应维度,SQL 必须使用 COUNT/SUM/AVG/MIN/MAX 等聚合函数,不能只返回明细行。",
1068
+ missing_items=("聚合函数 COUNT/SUM/AVG/MIN/MAX",),
1069
+ current_problem="当前 SQL 返回明细行,不能回答数量、最多值、排名或按维度统计问题。",
1070
+ repair_hint="按用户问题中的统计维度 GROUP BY,并用聚合函数生成指标。",
975
1071
  )
976
1072
 
977
1073
 
1074
+ def format_sql_quality_issue(issue: LowQualitySqlIssue) -> str:
1075
+ parts = [f"当前 SQL 不满足查询口径:{issue.message}"]
1076
+ if issue.missing_items:
1077
+ parts.append(f"缺失口径:{';'.join(issue.missing_items)}")
1078
+ if issue.current_problem:
1079
+ parts.append(f"当前 SQL 的问题:{issue.current_problem}")
1080
+ if issue.repair_hint:
1081
+ parts.append(f"建议修复方向:{issue.repair_hint}")
1082
+ return ";".join(parts)
1083
+
1084
+
978
1085
  def _question_requires_aggregate_sql(question: str) -> bool:
979
1086
  if not re.search(r"(计数|统计|数量|个数|总数|多少)", question):
980
1087
  return False
@@ -1207,6 +1314,7 @@ def _schema_invalid_join_issues(
1207
1314
  allowed_pairs = _schema_allowed_join_pairs(schema)
1208
1315
  if not allowed_pairs:
1209
1316
  return []
1317
+ derived_aliases = set(_sql_derived_subquery_aliases(sql))
1210
1318
  issues: list[SchemaValidationIssue] = []
1211
1319
  seen: set[tuple[tuple[str, str], tuple[str, str]]] = set()
1212
1320
  for pair in _sql_equality_pairs(sql, table_aliases):
@@ -1216,6 +1324,8 @@ def _schema_invalid_join_issues(
1216
1324
  if pair in allowed_pairs:
1217
1325
  continue
1218
1326
  left, right = pair
1327
+ if left[0] in derived_aliases or right[0] in derived_aliases:
1328
+ continue
1219
1329
  issues.append(
1220
1330
  SchemaValidationIssue(
1221
1331
  code="INVALID_JOIN",
@@ -1323,19 +1433,37 @@ def _sql_derived_subquery_spans(sql: str) -> list[tuple[int, int]]:
1323
1433
  ]
1324
1434
 
1325
1435
 
1436
+ _DERIVED_SUBQUERY_TRAILING = (
1437
+ r"on\b"
1438
+ r"|(?:(?:left|right|inner|outer|cross|natural)\s+)?join\b"
1439
+ r"|where\b|group\b|order\b|having\b|limit\b"
1440
+ r"|,"
1441
+ r"|\)(?!\s*[a-zA-Z_])"
1442
+ r"|;"
1443
+ )
1444
+ _DERIVED_SUBQUERY_ALIAS_PATTERN = re.compile(
1445
+ rf"\)\s*(?:as\s+)?([a-zA-Z_][a-zA-Z0-9_]*)(?:\s+(?:{_DERIVED_SUBQUERY_TRAILING})|\s*$)",
1446
+ flags=re.IGNORECASE,
1447
+ )
1448
+ _RESERVED_DERIVED_ALIASES = frozenset(
1449
+ {"on", "where", "left", "right", "inner", "outer", "join", "select", "cross", "natural", "group", "order", "having", "limit"}
1450
+ )
1451
+
1452
+
1326
1453
  def _sql_derived_subquery_matches(sql: str) -> list[tuple[str, int, int]]:
1327
1454
  matches: list[tuple[str, int, int]] = []
1328
- for match in re.finditer(
1329
- r"\)\s*(?:as\s+)?([a-zA-Z_][a-zA-Z0-9_]*)\s+on\b",
1330
- sql,
1331
- flags=re.IGNORECASE,
1332
- ):
1455
+ seen_ranges: set[tuple[int, int]] = set()
1456
+ for match in _DERIVED_SUBQUERY_ALIAS_PATTERN.finditer(sql):
1333
1457
  alias = match.group(1).lower()
1334
- if alias in {"on", "where", "left", "right", "inner", "outer", "join", "select"}:
1458
+ if alias in _RESERVED_DERIVED_ALIASES:
1335
1459
  continue
1336
1460
  subquery_start = _find_matching_open_paren(sql, match.start())
1337
1461
  if subquery_start is None:
1338
1462
  continue
1463
+ range_key = (subquery_start, match.start())
1464
+ if range_key in seen_ranges:
1465
+ continue
1466
+ seen_ranges.add(range_key)
1339
1467
  matches.append((alias, subquery_start, match.start()))
1340
1468
  return matches
1341
1469
 
@@ -1662,7 +1790,7 @@ def time_range_from_query_plan(query_plan: dict[str, Any] | None) -> TimeRange |
1662
1790
  start_value = ""
1663
1791
  end_value = ""
1664
1792
  field_name = ""
1665
- for flt in query_plan.get("filters") or []:
1793
+ for flt in _query_plan_sql_filters(query_plan):
1666
1794
  if not isinstance(flt, dict):
1667
1795
  continue
1668
1796
  if str(flt.get("source") or "") != "question.timeRange":
@@ -1853,7 +1981,34 @@ def resolve_time_range_for_sql(
1853
1981
 
1854
1982
  concepts = match_concepts_for_question(_schema_concepts(schema), question)
1855
1983
  sql_schema = _schema_limited_to_sql_tables(schema, sql)
1856
- return resolve_time_range_for_question(question, sql_schema or schema, concepts=concepts)
1984
+ if sql_schema is None and _sql_referenced_table_names(sql):
1985
+ return None
1986
+ if sql_schema is not None:
1987
+ return resolve_time_range_for_question(
1988
+ question,
1989
+ sql_schema,
1990
+ concepts=_concepts_limited_to_schema_tables(concepts, sql_schema),
1991
+ )
1992
+ return resolve_time_range_for_question(question, schema, concepts=concepts)
1993
+
1994
+
1995
+ def _concepts_limited_to_schema_tables(
1996
+ concepts: list[dict[str, Any]],
1997
+ schema: dict[str, Any],
1998
+ ) -> list[dict[str, Any]]:
1999
+ table_names = {name.lower() for name in _table_names(schema)}
2000
+ if not table_names:
2001
+ return []
2002
+ limited: list[dict[str, Any]] = []
2003
+ for concept in concepts:
2004
+ qualified = str(concept.get("timeRangeField") or "").strip()
2005
+ if "." not in qualified:
2006
+ limited.append(concept)
2007
+ continue
2008
+ table_name = qualified.split(".", 1)[0].lower()
2009
+ if table_name in table_names:
2010
+ limited.append(concept)
2011
+ return limited
1857
2012
 
1858
2013
 
1859
2014
  def _qualified_time_range_field(time_range: TimeRange) -> str:
@@ -2004,7 +2159,7 @@ def sql_missing_query_plan_filters(sql: str, query_plan: dict[str, Any] | None)
2004
2159
  missing: list[str] = []
2005
2160
  normalized_sql = sql.lower()
2006
2161
  grouped_filters: dict[str, list[dict[str, Any]]] = {}
2007
- for flt in query_plan.get("filters") or []:
2162
+ for flt in _query_plan_sql_filters(query_plan):
2008
2163
  if not isinstance(flt, dict):
2009
2164
  continue
2010
2165
  if str(flt.get("operator") or "").upper() != "LIKE":
@@ -2045,6 +2200,14 @@ def sql_satisfies_query_plan_filters(sql: str, query_plan: dict[str, Any] | None
2045
2200
  return not sql_missing_query_plan_filters(sql, query_plan)
2046
2201
 
2047
2202
 
2203
+ def _query_plan_sql_filters(query_plan: dict[str, Any]) -> list[Any]:
2204
+ filters = query_plan.get("_sqlFilters")
2205
+ if isinstance(filters, list):
2206
+ return filters
2207
+ filters = query_plan.get("filters")
2208
+ return filters if isinstance(filters, list) else []
2209
+
2210
+
2048
2211
  def _or_group_filters_connected_by_or(normalized_sql: str, filters: list[dict[str, Any]]) -> bool:
2049
2212
  if not re.search(r"\bor\b", normalized_sql):
2050
2213
  return False