Skip to content

Commit ea36fee

Browse files
author
luoyang42
committed
!395 merge bugfix_0905_nl2sql into main
fix(nl2sql): 兼容 FE 顶层 schema 角色,并用 arraySum 避免评分卡 AST 超深 Created-by: TZzzerlay Commit-by: xuzixiang Merged-by: luoyang42 Description: fix(nl2sql): 兼容 FE 顶层 schema 角色,并用 arraySum 避免评分卡 AST 超深 特征工程可能不写 roles,生成器会因此直接退出;千条以上评分卡用 + 串联 if() 会超过 ClickHouse AST 深度 1000。不可部署时的 SystemExit 补上解析和血缘诊断,并禁止子代理通读生成器脚本以免撑爆上下文。 See merge request: datagallery/dataagent!395
2 parents 526cdcc + c827716 commit ea36fee

2 files changed

Lines changed: 127 additions & 14 deletions

File tree

runtime/dataagent/dataagent/core/suite/builtin_suites/data_analysis/skill/nl2sql/SKILL.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,10 @@ LightGBM 教师参照:
174174
```
175175

176176
再次运行 step4_1,由生成器核对 hash、判定结果并生成 receipt。返回零行属于执行成功;
177-
ClickHouse 语法、字段、函数或类型错误时修改生成器工作副本并重跑。资源限额错误必须在报告
178-
中单独标识,不能冒充 SQL 语法错误;禁止对相同 sha256 的失败试算原样重复提交。
177+
ClickHouse 语法、字段、函数或类型错误时,先根据试算报错和生成器 SystemExit 诊断判断;
178+
仅当确认是生成逻辑缺陷时,才按下方“修改工作副本”规则做定点修改并重跑。
179+
禁止为此通读脚本。资源限额错误必须在报告中单独标识,不能冒充 SQL 语法错误;
180+
禁止对相同 sha256 的失败试算原样重复提交。
179181

180182
## 分步脚本与运行方式
181183

@@ -225,6 +227,14 @@ uv run --no-sync python "${OUTPUT_DIR}/scripts/step4_1_generate_sql.py"
225227
SQL 静态或 ClickHouse 验证要求,可以修改发生问题的对应工作副本后从该步骤重跑。修改时:
226228

227229
- 确认问题来自模板兼容或生成逻辑,而不是通过改写规则、标签或分数规避输入事实。
230+
- 禁止通读 `step4_0_*.py` / `step4_1_generate_sql.py`(打包模板或工作副本)。
231+
已知故障:整文件 Read 会撑爆上下文,LLM 流停滞数十分钟直至 job 被取消。
232+
- 定位问题时只用 `rg` / `grep` 搜报错里的函数名、特征名或 ClickHouse 错误码,
233+
单次 Read 不超过该函数本身。禁止为“理解生成器”而分页读完全文。
234+
- 报错已写明缺失字段、血缘或 SQL 诊断时,先看 SystemExit / 报告 / 生成 SQL,
235+
不要打开脚本。
236+
- 同一类失败(同一 sha256 试算、同一 SystemExit 前缀)最多改工作副本 2 次;
237+
仍失败则停在 receipt,写阻塞原因,不要继续读源码探索。
228238
- 建议在修改 step4_0 时设置 `NL2SQL_PREPROCESS_CHANGE_REASON`,修改 step4_1 时设置
229239
`NL2SQL_GENERATOR_CHANGE_REASON`,记录修改原因;缺失原因只记审计 warning,不拒绝运行。
230240
- 保持打包模板不变,不手工编辑生成后的 SQL、报告或 receipt。
@@ -245,6 +255,7 @@ export NL2SQL_TREE_SCORE_TOLERANCE="0.00051"
245255

246256
禁止:
247257

258+
- 通读或分页读完 step4_0 / step4_1 生成器脚本。
248259
- 修改共享的打包生成器模板。
249260
- 修改共享输出区中的任何上游文件。
250261
- 使用本地 Python 改写模型规则、验证标签、预测分数或策略指标。

runtime/dataagent/dataagent/core/suite/builtin_suites/data_analysis/skill/nl2sql/scripts/step4_1_generate_sql.py

Lines changed: 114 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,40 @@ def _hint_game_key_candidates(output_meta: dict[str, Any]) -> dict[str, list[str
357357
return candidates
358358

359359

360+
def _first_nonempty_text(*values: Any) -> str:
361+
"""Return the first non-empty stripped string among ``values``."""
362+
for value in values:
363+
text = str(value or "").strip()
364+
if text:
365+
return text
366+
return ""
367+
368+
369+
def _schema_role_value(schema_resolution: dict[str, Any], *names: str) -> str:
370+
"""Resolve a named schema role from ``roles`` first, then top-level keys.
371+
372+
Feature Engineering may emit ``user_table`` / ``user_id`` at the document
373+
root instead of nesting them under ``roles``. Both shapes are accepted;
374+
``roles`` wins when both are present.
375+
"""
376+
roles = schema_resolution.get("roles")
377+
role_map = roles if isinstance(roles, dict) else {}
378+
from_roles = _first_nonempty_text(*(role_map.get(name) for name in names))
379+
if from_roles:
380+
return from_roles
381+
from_top = _first_nonempty_text(*(schema_resolution.get(name) for name in names))
382+
if from_top:
383+
INPUT_NORMALIZATION_WARNINGS.append(
384+
"schema_resolution resolved "
385+
+ "/".join(names)
386+
+ " from top-level keys because roles did not provide them"
387+
)
388+
return from_top
389+
return ""
390+
391+
360392
def load_runtime_contract() -> RuntimeContract:
393+
"""Load databases, user keys, and table roles from step1 plus schema_resolution."""
361394
output_meta = _read_json("step1_output_meta.json")
362395
sample_stats = _read_json("step1_sample_stats.json")
363396
schema_resolution = _read_json("schema_resolution.json")
@@ -380,13 +413,14 @@ def load_runtime_contract() -> RuntimeContract:
380413
if not sampling_database:
381414
raise SystemExit("step1_sample_stats.json must provide output_database")
382415
target_game = str(sample_stats.get("target_game") or "").strip()
383-
roles = schema_resolution.get("roles", {})
384-
if not isinstance(roles, dict):
385-
raise SystemExit("schema_resolution.roles must be an object")
386-
user_table = str(roles.get("<user_table>") or roles.get("user_table") or "").strip()
387-
user_id = str(roles.get("<user_id>") or roles.get("user_id") or "").strip()
416+
user_table = _schema_role_value(schema_resolution, "<user_table>", "user_table")
417+
user_id = _schema_role_value(schema_resolution, "<user_id>", "user_id")
388418
if not user_table or not user_id:
389-
raise SystemExit("schema_resolution must resolve <user_table> and <user_id>")
419+
available = ", ".join(sorted(str(key) for key in schema_resolution.keys()))
420+
raise SystemExit(
421+
"schema_resolution must resolve <user_table> and <user_id> "
422+
f"from roles or top-level keys; available keys: {available}"
423+
)
390424

391425
table_columns = _table_schema_map(output_meta)
392426
if user_table not in table_columns:
@@ -454,7 +488,7 @@ def load_runtime_contract() -> RuntimeContract:
454488
)
455489
)
456490

457-
role_game_key = str(roles.get("<game_id>") or roles.get("game_id") or "").strip()
491+
role_game_key = _schema_role_value(schema_resolution, "<game_id>", "game_id")
458492
hint_candidates = _hint_game_key_candidates(output_meta)
459493
game_keys: dict[str, str] = {}
460494
for table in sorted(game_dimension_tables):
@@ -1445,6 +1479,22 @@ def build_tree_candidate(path: Path) -> CandidateSQL:
14451479
)
14461480

14471481

1482+
def _scorecard_score_expression(branches: list[str]) -> str:
1483+
"""Combine per-rule ``if(...)`` branches into one ClickHouse score expression.
1484+
1485+
Joining branches with left-associative ``+`` makes ClickHouse nest binary
1486+
``Plus`` nodes to depth ``len(branches) - 1``. That exceeds the default
1487+
AST depth limit of 1000 once a scorecard has more than about 1000 rules.
1488+
``arraySum`` keeps depth constant regardless of rule count.
1489+
"""
1490+
if not branches:
1491+
return "CAST(0 AS Float64)"
1492+
if len(branches) == 1:
1493+
return branches[0]
1494+
inner = ",\n ".join(branches)
1495+
return f"arraySum([\n {inner}\n ])"
1496+
1497+
14481498
def parse_scorecard_condition(feature: str, condition: str, alias: str = "features") -> str:
14491499
reference = f"{alias}.{_quote_identifier(feature)}"
14501500
text = str(condition).strip()
@@ -1467,6 +1517,7 @@ def parse_scorecard_condition(feature: str, condition: str, alias: str = "featur
14671517

14681518

14691519
def build_scorecard_candidate(path: Path) -> CandidateSQL:
1520+
"""Parse a scorecard rule CSV into a deployable ClickHouse score expression."""
14701521
frame = pd.read_csv(path, encoding="utf-8-sig")
14711522
required = {"feature", "condition", "weighted_score"}
14721523
if not required.issubset(frame.columns):
@@ -1485,7 +1536,7 @@ def build_scorecard_candidate(path: Path) -> CandidateSQL:
14851536
features.add(feature)
14861537
branches.append(f"if({condition}, toFloat64({score}), toFloat64(0))")
14871538
coverage = len(branches) / len(frame) if len(frame) else 0.0
1488-
expression = "\n + ".join(branches) if branches else "CAST(0 AS Float64)"
1539+
expression = _scorecard_score_expression(branches)
14891540
return CandidateSQL(
14901541
name="scorecard",
14911542
expression=expression,
@@ -2496,11 +2547,63 @@ def _risk_flags(name: str, metrics: dict[str, Any], rule_count: int) -> list[str
24962547
return flags
24972548

24982549

2550+
_MAX_DIAGNOSTIC_ERRORS = 8
2551+
_MAX_DIAGNOSTIC_ERROR_CHARS = 300
2552+
2553+
2554+
def _truncate_diagnostic_error(text: str) -> str:
2555+
"""Keep one diagnostic line short enough for an LLM to read in a SystemExit."""
2556+
cleaned = str(text or "").strip()
2557+
if len(cleaned) <= _MAX_DIAGNOSTIC_ERROR_CHARS:
2558+
return cleaned
2559+
return cleaned[: _MAX_DIAGNOSTIC_ERROR_CHARS - 3] + "..."
2560+
2561+
2562+
def _format_diagnostic_errors(errors: list[str] | None) -> str:
2563+
"""Join parse or deployment errors into a compact SystemExit fragment."""
2564+
items = [_truncate_diagnostic_error(item) for item in (errors or [])]
2565+
items = [item for item in items if item]
2566+
if not items:
2567+
return "(none)"
2568+
shown = items[:_MAX_DIAGNOSTIC_ERRORS]
2569+
extra = len(items) - len(shown)
2570+
body = "; ".join(shown)
2571+
if extra > 0:
2572+
return f"{body}; ... and {extra} more"
2573+
return body
2574+
2575+
2576+
def _format_undeployable_candidate(candidate: CandidateSQL) -> str:
2577+
"""Summarize why one white-box candidate cannot be deployed."""
2578+
return (
2579+
f"{candidate.name}: renderable={candidate.renderable}"
2580+
f", rule_count={candidate.rule_count}"
2581+
f", parse_coverage={candidate.parse_coverage}"
2582+
f", parse_errors={_format_diagnostic_errors(candidate.render_errors)}"
2583+
f", deployment_errors={_format_diagnostic_errors(candidate.deployment_errors)}"
2584+
)
2585+
2586+
2587+
def _undeployable_whitebox_exit_message(tree: CandidateSQL, scorecard: CandidateSQL) -> str:
2588+
"""Build a SystemExit payload that tells the LLM which candidate failed and why."""
2589+
return (
2590+
"Neither white-box candidate is deployable; "
2591+
"this is an invalid technical input or lineage contract, not a model-quality gate. "
2592+
"Do not rewrite rule scores, labels, or predictions. "
2593+
"Inspect schema_resolution, rule CSV conditions, tree preprocessing, "
2594+
"and feature lineage using the diagnostics below. "
2595+
+ _format_undeployable_candidate(tree)
2596+
+ " ; "
2597+
+ _format_undeployable_candidate(scorecard)
2598+
)
2599+
2600+
24992601
def choose_strategy(
25002602
aligned: pd.DataFrame,
25012603
tree: CandidateSQL,
25022604
scorecard: CandidateSQL,
25032605
) -> tuple[dict[str, Any], dict[str, float]]:
2606+
"""Compare deployable tree and scorecard candidates and pick one strategy."""
25042607
labels = aligned["label"].to_numpy(dtype=int)
25052608
teacher_scores = aligned["teacher_score"].to_numpy(dtype=float)
25062609
tree_scores = aligned["tree_score"].to_numpy(dtype=float)
@@ -2522,10 +2625,7 @@ def choose_strategy(
25222625
card_metrics["risk_flags"] = _risk_flags("scorecard", card_metrics, scorecard.rule_count)
25232626

25242627
if not tree.renderable and not scorecard.renderable:
2525-
raise SystemExit(
2526-
"Neither white-box rule artifact is fully parseable; "
2527-
"this is an invalid technical input contract, not a model-quality gate"
2528-
)
2628+
raise SystemExit(_undeployable_whitebox_exit_message(tree, scorecard))
25292629
if tree.renderable != scorecard.renderable:
25302630
strategy = "decision_tree" if tree.renderable else "scorecard"
25312631
return (
@@ -3191,6 +3291,8 @@ def render_features(
31913291
raise SystemExit(
31923292
"Cannot render a deployable white-box SQL after lineage normalization: "
31933293
+ str(exc)
3294+
+ ". "
3295+
+ _undeployable_whitebox_exit_message(tree, scorecard)
31943296
) from exc
31953297
fallback = alternatives[0]
31963298
strategy = fallback.name

0 commit comments

Comments
 (0)