@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.
@@ -25,6 +25,7 @@ from .sql_generator import (
25
25
  build_sql_rule_validation_error,
26
26
  enforce_sql_rules,
27
27
  finalize_generated_sql,
28
+ format_sql_quality_issue,
28
29
  _is_business_query,
29
30
  is_low_quality_sql_for_question,
30
31
  low_quality_sql_issue_for_question,
@@ -365,8 +366,14 @@ class SpongeAgent:
365
366
  if schema is not None:
366
367
  candidate_sql = apply_time_range_filters_from_question(candidate_sql, time_range_question, schema)
367
368
  if not sql_satisfies_time_range(candidate_sql, time_range_question, schema, query_plan=query_plan):
369
+ quality_message = _query_quality_failure_message(
370
+ "用户问题包含时间范围,SQL 必须在 WHERE 条件中直接包含对应时间字段过滤。",
371
+ missing_items=["用户要求的时间范围过滤"],
372
+ current_problem="当前 SQL 没有覆盖用户问题中的时间范围,执行后可能返回全量或错误时间段的数据。",
373
+ repair_hint="按 schema concepts/metrics 或查询计划中的时间字段补充 WHERE 时间范围条件。",
374
+ )
368
375
  if sampler is None or schema_context is None or generator is None:
369
- reason = "SQL 缺少用户问题要求的时间范围过滤,且当前没有 sampler 可修复。"
376
+ reason = f"{quality_message};当前没有 sampler 可修复。"
370
377
  steps.error(8, reason)
371
378
  return _attach_execution_steps(_query_only_result(reason), steps)
372
379
  repaired_sql, fail_reason = _repair_sql_with_llm_retries(
@@ -375,8 +382,11 @@ class SpongeAgent:
375
382
  sql=candidate_sql,
376
383
  validation_error={
377
384
  "code": "MISSING_TIME_RANGE_FILTER",
378
- "message": "用户问题包含时间范围,SQL 必须在 WHERE 条件中直接包含对应时间字段过滤。",
385
+ "message": quality_message,
379
386
  "originalSql": candidate_sql,
387
+ "missingItems": ["用户要求的时间范围过滤"],
388
+ "currentProblem": "当前 SQL 没有覆盖用户问题中的时间范围。",
389
+ "repairHint": "按 schema concepts/metrics 或查询计划中的时间字段补充 WHERE 时间范围条件。",
380
390
  },
381
391
  schema_context=sql_schema_context,
382
392
  audit_logger=self.audit_logger,
@@ -387,8 +397,8 @@ class SpongeAgent:
387
397
  schema=schema,
388
398
  check=lambda candidate: _require_time_range(candidate, time_range_question, schema, query_plan),
389
399
  steps=steps,
390
- running_detail="SQL 缺少时间范围过滤,尝试修复",
391
- failure_detail="SQL 缺少时间范围过滤,修复失败",
400
+ running_detail=f"SQL 口径校验失败,尝试修复:{quality_message}",
401
+ failure_detail=f"SQL 口径校验失败,修复失败:{quality_message}",
392
402
  )
393
403
  if repaired_sql is None:
394
404
  steps.error(9, fail_reason)
@@ -398,8 +408,14 @@ class SpongeAgent:
398
408
  steps.done(9, "已补充时间范围过滤。")
399
409
  if query_plan and not sql_satisfies_query_plan_filters(candidate_sql, query_plan):
400
410
  missing_filters = sql_missing_query_plan_filters(candidate_sql, query_plan)
411
+ quality_message = _query_quality_failure_message(
412
+ f"SQL 缺少查询计划中的筛选条件:{'; '.join(missing_filters)}。",
413
+ missing_items=missing_filters,
414
+ current_problem="当前 SQL 没有覆盖用户已确认的关键筛选,执行后可能返回范围过宽或不相关的数据。",
415
+ repair_hint="必须 JOIN 相关表,并把缺失的 table.column 过滤写入 WHERE;同组 OR 条件需要用括号包裹。",
416
+ )
401
417
  if sampler is None or schema_context is None or generator is None:
402
- reason = f"SQL 缺少查询计划中的筛选条件:{'; '.join(missing_filters)}"
418
+ reason = quality_message
403
419
  steps.error(8, reason)
404
420
  return _attach_execution_steps(_query_only_result(reason), steps)
405
421
  repaired_sql, fail_reason = _repair_sql_with_llm_retries(
@@ -408,12 +424,12 @@ class SpongeAgent:
408
424
  sql=candidate_sql,
409
425
  validation_error={
410
426
  "code": "MISSING_QUERY_PLAN_FILTER",
411
- "message": (
412
- f"SQL 缺少查询计划中的筛选条件:{'; '.join(missing_filters)}。"
413
- "必须 JOIN 相关表并在 WHERE 中包含全部 table.column 过滤。"
414
- ),
427
+ "message": quality_message,
415
428
  "originalSql": candidate_sql,
416
429
  "missingFilters": missing_filters,
430
+ "missingItems": missing_filters,
431
+ "currentProblem": "当前 SQL 没有覆盖用户已确认的关键筛选。",
432
+ "repairHint": "必须 JOIN 相关表,并把缺失的 table.column 过滤写入 WHERE。",
417
433
  },
418
434
  schema_context=sql_schema_context,
419
435
  audit_logger=self.audit_logger,
@@ -424,8 +440,8 @@ class SpongeAgent:
424
440
  schema=schema,
425
441
  check=lambda candidate: sql_satisfies_query_plan_filters(candidate, query_plan),
426
442
  steps=steps,
427
- running_detail=f"SQL 缺少查询计划筛选条件,尝试修复:{'; '.join(missing_filters)}",
428
- failure_detail="SQL 缺少查询计划筛选条件,修复失败",
443
+ running_detail=f"SQL 口径校验失败,尝试修复:{quality_message}",
444
+ failure_detail=f"SQL 口径校验失败,修复失败:{quality_message}",
429
445
  )
430
446
  if repaired_sql is None:
431
447
  steps.error(9, fail_reason)
@@ -435,8 +451,14 @@ class SpongeAgent:
435
451
  steps.done(9, "已补充查询计划筛选条件。")
436
452
  if query_plan and not sql_satisfies_query_plan_select_fields(candidate_sql, query_plan):
437
453
  missing_select_fields = sql_missing_query_plan_select_fields(candidate_sql, query_plan)
454
+ quality_message = _query_quality_failure_message(
455
+ f"SQL 缺少查询计划中的展示字段:{'; '.join(missing_select_fields)}。",
456
+ missing_items=missing_select_fields,
457
+ current_problem="当前 SQL 没有展示用户已确认的必要字段,结果无法解释或无法区分命中的实体。",
458
+ repair_hint="把缺失字段加入 SELECT,并使用中文别名。",
459
+ )
438
460
  if sampler is None or schema_context is None or generator is None:
439
- reason = f"SQL 缺少查询计划中的展示字段:{'; '.join(missing_select_fields)}"
461
+ reason = quality_message
440
462
  steps.error(8, reason)
441
463
  return _attach_execution_steps(_query_only_result(reason), steps)
442
464
  repaired_sql, fail_reason = _repair_sql_with_llm_retries(
@@ -445,12 +467,12 @@ class SpongeAgent:
445
467
  sql=candidate_sql,
446
468
  validation_error={
447
469
  "code": "MISSING_QUERY_PLAN_SELECT_FIELD",
448
- "message": (
449
- f"SQL 缺少查询计划中的展示字段:{'; '.join(missing_select_fields)}。"
450
- "同一别称命中多个实体类型时,必须在 SELECT 中展示各实体名称字段,方便用户自行缩小范围。"
451
- ),
470
+ "message": quality_message,
452
471
  "originalSql": candidate_sql,
453
472
  "missingSelectFields": missing_select_fields,
473
+ "missingItems": missing_select_fields,
474
+ "currentProblem": "当前 SQL 没有展示用户已确认的必要字段。",
475
+ "repairHint": "把缺失字段加入 SELECT,并使用中文别名。",
454
476
  },
455
477
  schema_context=sql_schema_context,
456
478
  audit_logger=self.audit_logger,
@@ -461,8 +483,8 @@ class SpongeAgent:
461
483
  schema=schema,
462
484
  check=lambda candidate: sql_satisfies_query_plan_select_fields(candidate, query_plan),
463
485
  steps=steps,
464
- running_detail=f"SQL 缺少查询计划展示字段,尝试修复:{'; '.join(missing_select_fields)}",
465
- failure_detail="SQL 缺少查询计划展示字段,修复失败",
486
+ running_detail=f"SQL 口径校验失败,尝试修复:{quality_message}",
487
+ failure_detail=f"SQL 口径校验失败,修复失败:{quality_message}",
466
488
  )
467
489
  if repaired_sql is None:
468
490
  steps.error(9, fail_reason)
@@ -472,17 +494,21 @@ class SpongeAgent:
472
494
  steps.done(9, "已补充查询计划展示字段。")
473
495
  low_quality_issue = low_quality_sql_issue_for_question(sql_question, candidate_sql, schema=schema)
474
496
  if low_quality_issue is not None:
497
+ quality_message = format_sql_quality_issue(low_quality_issue)
475
498
  if sampler is None or schema_context is None or generator is None:
476
- steps.error(8, low_quality_issue.message)
477
- return _attach_execution_steps(_low_quality_sql_result(low_quality_issue.message, candidate_sql), steps)
499
+ steps.error(8, quality_message)
500
+ return _attach_execution_steps(_low_quality_sql_result(quality_message, candidate_sql), steps)
478
501
  repaired_sql, fail_reason = _repair_sql_with_llm_retries(
479
502
  generator=generator,
480
503
  question=sql_question,
481
504
  sql=candidate_sql,
482
505
  validation_error={
483
506
  "code": low_quality_issue.code,
484
- "message": low_quality_issue.message,
507
+ "message": quality_message,
485
508
  "originalSql": candidate_sql,
509
+ "missingItems": list(low_quality_issue.missing_items),
510
+ "currentProblem": low_quality_issue.current_problem,
511
+ "repairHint": low_quality_issue.repair_hint,
486
512
  },
487
513
  schema_context=sql_schema_context,
488
514
  audit_logger=self.audit_logger,
@@ -493,8 +519,8 @@ class SpongeAgent:
493
519
  schema=schema,
494
520
  check=lambda candidate: _require_low_quality_pass(candidate, sql_question, schema),
495
521
  steps=steps,
496
- running_detail=f"SQL 质量检查失败,尝试修复:{low_quality_issue.message}",
497
- failure_detail=f"SQL 质量检查失败,修复失败:{low_quality_issue.message}",
522
+ running_detail=f"SQL 口径校验失败,尝试修复:{quality_message}",
523
+ failure_detail=f"SQL 口径校验失败,修复失败:{quality_message}",
498
524
  )
499
525
  if repaired_sql is None:
500
526
  steps.error(9, fail_reason)
@@ -634,6 +660,28 @@ class SpongeAgent:
634
660
  return _attach_execution_steps(_query_only_result(schema_fail), steps)
635
661
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
636
662
  candidate_sql = _remove_noise_name_like_filters_from_sql(candidate_sql, analysis=analysis)
663
+ if candidate_sql != prior_sql:
664
+ generated = GeneratedSql(sql="", tables=[], placeholders=[])
665
+ repaired_sql = candidate_sql
666
+ prior_sql = candidate_sql
667
+ candidate_sql, expert_fail = _review_sql_with_llm_expert_if_needed(
668
+ generator=generator,
669
+ question=sql_question,
670
+ sql=candidate_sql,
671
+ schema_context=sql_schema_context,
672
+ schema=schema,
673
+ query_plan=query_plan,
674
+ time_range_question=time_range_question,
675
+ default_limit=self.config.sql.default_limit,
676
+ max_limit=self.config.sql.max_limit,
677
+ max_repair_attempts=self.config.sql.max_repair_attempts,
678
+ steps=steps,
679
+ audit_logger=self.audit_logger,
680
+ session_id=context.session_id,
681
+ )
682
+ if expert_fail:
683
+ steps.error(10, expert_fail)
684
+ return _attach_execution_steps(_query_only_result(expert_fail), steps)
637
685
  if candidate_sql != prior_sql:
638
686
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
639
687
  repaired_sql = candidate_sql
@@ -790,7 +838,66 @@ class SpongeAgent:
790
838
  result = self._execute_sql(context, user_question, normalized_sql)
791
839
  except Exception as exc:
792
840
  unknown_col = _parse_execute_unknown_column(str(exc))
793
- if (
841
+ invalid_group_function = _is_execute_invalid_group_function(str(exc))
842
+ if invalid_group_function and sampler is not None and generator is not None and schema_context is not None:
843
+ exec_error = {
844
+ "code": "MYSQL_INVALID_GROUP_FUNCTION_USE",
845
+ "message": (
846
+ "MySQL 执行失败:Invalid use of group function。"
847
+ "通常是聚合函数写在 WHERE 或 JOIN ON 中,或聚合筛选没有放到 HAVING/子查询外层。"
848
+ ),
849
+ "originalSql": normalized_sql,
850
+ "repairHint": "把聚合条件移动到 HAVING,或先用子查询聚合后在外层 WHERE 过滤;不要改变业务口径。",
851
+ }
852
+ repaired_exec_sql, fail_reason = _repair_sql_with_llm_retries(
853
+ generator=generator,
854
+ question=sql_question,
855
+ sql=normalized_sql,
856
+ validation_error=exec_error,
857
+ schema_context=sql_schema_context,
858
+ audit_logger=self.audit_logger,
859
+ session_id=context.session_id,
860
+ max_attempts=self.config.sql.max_repair_attempts,
861
+ default_limit=self.config.sql.default_limit,
862
+ max_limit=self.config.sql.max_limit,
863
+ schema=schema,
864
+ check=lambda candidate: _require_sql_expert_local_pass(candidate, schema, self.config.sql.max_limit),
865
+ steps=steps,
866
+ running_detail="execute_sql 报聚合函数用法错误,尝试修复",
867
+ failure_detail="execute_sql 聚合函数用法错误修复失败",
868
+ )
869
+ if repaired_exec_sql is None:
870
+ reason = fail_reason or f"{type(exc).__name__}: {exc}"
871
+ steps.error(11, reason)
872
+ return _attach_execution_steps(_query_only_result(reason), steps)
873
+ if query_plan:
874
+ repaired_exec_sql = apply_time_range_filters_from_plan(repaired_exec_sql, query_plan)
875
+ if schema is not None:
876
+ repaired_exec_sql = apply_time_range_filters_from_question(repaired_exec_sql, time_range_question, schema)
877
+ try:
878
+ _require_sql_expert_local_pass(repaired_exec_sql, schema, self.config.sql.max_limit)
879
+ revalidated = self._validate_sql(context, user_question, repaired_exec_sql)
880
+ except Exception as validate_exc:
881
+ reason = f"{type(validate_exc).__name__}: {validate_exc}"
882
+ steps.error(10, reason)
883
+ return _attach_execution_steps(_query_only_result(reason), steps)
884
+ if revalidated is None:
885
+ reason = (
886
+ f"execute 聚合函数用法修复后 validate_sql 失败:"
887
+ f"{_validation_error_message(self._last_validation_result)}"
888
+ )
889
+ steps.error(10, reason)
890
+ return _attach_execution_steps(_query_only_result(reason), steps)
891
+ try:
892
+ result = self._execute_sql(context, user_question, revalidated)
893
+ normalized_sql = revalidated
894
+ repaired_sql = repaired_exec_sql
895
+ steps.done(11, f"execute_sql 完成,rowCount={result.get('rowCount')}")
896
+ except Exception as retry_exc:
897
+ reason = f"{type(retry_exc).__name__}: {retry_exc}"
898
+ steps.error(11, reason)
899
+ return _attach_execution_steps(_query_only_result(reason), steps)
900
+ elif (
794
901
  unknown_col
795
902
  and sampler is not None
796
903
  and generator is not None
@@ -1303,7 +1410,7 @@ def _build_query_plan(
1303
1410
  analysis=analysis,
1304
1411
  )
1305
1412
  selected_concepts = match_concepts_for_question(getattr(schema_context, "concepts", []) or [], question)
1306
- filters = _query_plan_filters(
1413
+ sql_filters = _query_plan_sql_filters(
1307
1414
  question,
1308
1415
  schema_context,
1309
1416
  searched_entities=searched_entities,
@@ -1313,6 +1420,11 @@ def _build_query_plan(
1313
1420
  selected_concepts=selected_concepts,
1314
1421
  analysis=analysis,
1315
1422
  )
1423
+ filters = _query_plan_semantic_filters(
1424
+ question,
1425
+ sql_filters=sql_filters,
1426
+ analysis=analysis,
1427
+ )
1316
1428
  entities = _query_plan_entities(
1317
1429
  question,
1318
1430
  schema_context,
@@ -1340,6 +1452,7 @@ def _build_query_plan(
1340
1452
  "tables": tables,
1341
1453
  "fields": fields,
1342
1454
  "filters": filters,
1455
+ "_sqlFilters": sql_filters,
1343
1456
  "entities": entities,
1344
1457
  "matchedConcepts": [
1345
1458
  {
@@ -1390,7 +1503,7 @@ def _ensure_query_plan_bridge_tables(plan: dict[str, Any]) -> None:
1390
1503
  job_tables = {"job", "job_basic_info", "job_address", "job_store", "job_hiring_requirement"}
1391
1504
  has_store = "store" in names
1392
1505
  if not has_store:
1393
- for flt in plan.get("filters") or []:
1506
+ for flt in _plan_sql_filters(plan):
1394
1507
  if isinstance(flt, dict) and str(flt.get("field") or "").startswith("store."):
1395
1508
  has_store = True
1396
1509
  break
@@ -1420,7 +1533,7 @@ def _ensure_query_plan_entity_display_fields(plan: dict[str, Any]) -> None:
1420
1533
  for item in fields
1421
1534
  if isinstance(item, dict)
1422
1535
  }
1423
- for flt in plan.get("filters") or []:
1536
+ for flt in _plan_sql_filters(plan):
1424
1537
  if not isinstance(flt, dict):
1425
1538
  continue
1426
1539
  if str(flt.get("source") or "") != "search_entities":
@@ -1460,6 +1573,14 @@ def _ensure_query_plan_entity_display_fields(plan: dict[str, Any]) -> None:
1460
1573
  existing.add(key)
1461
1574
 
1462
1575
 
1576
+ def _plan_sql_filters(plan: dict[str, Any]) -> list[Any]:
1577
+ filters = plan.get("_sqlFilters")
1578
+ if isinstance(filters, list):
1579
+ return filters
1580
+ filters = plan.get("filters")
1581
+ return filters if isinstance(filters, list) else []
1582
+
1583
+
1463
1584
  def _query_plan_tables(tables: list[dict[str, Any]]) -> list[dict[str, str]]:
1464
1585
  result: list[dict[str, str]] = []
1465
1586
  for table in tables[:10]:
@@ -2653,6 +2774,172 @@ def _query_plan_filters(
2653
2774
  time_range_question: str | None = None,
2654
2775
  selected_concepts: list[dict[str, Any]] | None = None,
2655
2776
  analysis: QuestionAnalysis | None = None,
2777
+ ) -> list[dict[str, str]]:
2778
+ sql_filters = _query_plan_sql_filters(
2779
+ question,
2780
+ schema_context,
2781
+ searched_entities=searched_entities,
2782
+ question_entities=question_entities,
2783
+ schema=schema,
2784
+ time_range_question=time_range_question,
2785
+ selected_concepts=selected_concepts,
2786
+ analysis=analysis,
2787
+ )
2788
+ return _query_plan_semantic_filters(
2789
+ question,
2790
+ sql_filters=sql_filters,
2791
+ analysis=analysis,
2792
+ )
2793
+
2794
+
2795
+ def _query_plan_semantic_filters(
2796
+ question: str,
2797
+ *,
2798
+ sql_filters: list[dict[str, str]],
2799
+ analysis: QuestionAnalysis | None = None,
2800
+ ) -> list[dict[str, str]]:
2801
+ filters: list[dict[str, str]] = []
2802
+
2803
+ def append_filter(item: dict[str, str]) -> None:
2804
+ field = str(item.get("field") or "").strip()
2805
+ value = str(item.get("value") or "").strip()
2806
+ if not field or not value:
2807
+ return
2808
+ operator = str(item.get("operator") or "=").strip() or "="
2809
+ normalized = {**item, "field": field, "operator": operator, "value": value}
2810
+ key = (
2811
+ field,
2812
+ operator.upper(),
2813
+ value,
2814
+ str(normalized.get("logicalGroup") or ""),
2815
+ str(normalized.get("logicalOperator") or ""),
2816
+ )
2817
+ if any(
2818
+ (
2819
+ existing.get("field"),
2820
+ str(existing.get("operator") or "").upper(),
2821
+ existing.get("value"),
2822
+ str(existing.get("logicalGroup") or ""),
2823
+ str(existing.get("logicalOperator") or ""),
2824
+ )
2825
+ == key
2826
+ for existing in filters
2827
+ ):
2828
+ return
2829
+ filters.append(normalized)
2830
+
2831
+ if analysis is not None:
2832
+ for item in analysis.filters:
2833
+ if not isinstance(item, dict):
2834
+ continue
2835
+ append_filter(
2836
+ {
2837
+ "field": str(item.get("field") or "筛选条件"),
2838
+ "operator": str(item.get("operator") or "="),
2839
+ "value": str(item.get("value") or ""),
2840
+ "source": "llm.semanticFilter",
2841
+ }
2842
+ )
2843
+ time_filter = _semantic_time_filter_from_analysis(analysis)
2844
+ if time_filter is not None:
2845
+ append_filter(time_filter)
2846
+
2847
+ if not any(str(item.get("source") or "") == "question.timeRange" for item in filters):
2848
+ time_filter = _semantic_time_filter_from_sql_filters(sql_filters)
2849
+ if time_filter is not None:
2850
+ append_filter(time_filter)
2851
+
2852
+ for item in sql_filters:
2853
+ semantic = _semantic_filter_from_sql_filter(item)
2854
+ if semantic is not None:
2855
+ append_filter(semantic)
2856
+ return filters
2857
+
2858
+
2859
+ def _semantic_time_filter_from_analysis(analysis: QuestionAnalysis) -> dict[str, str] | None:
2860
+ time_range = analysis.time_range
2861
+ if not isinstance(time_range, dict):
2862
+ return None
2863
+ value = str(time_range.get("originalText") or time_range.get("value") or "").strip()
2864
+ if not value:
2865
+ start = str(time_range.get("start") or "").strip()
2866
+ end = str(time_range.get("endExclusive") or "").strip()
2867
+ if start and end:
2868
+ value = f"{start} 至 {end}"
2869
+ if not value:
2870
+ return None
2871
+ return {
2872
+ "field": "时间范围",
2873
+ "operator": "=",
2874
+ "value": value,
2875
+ "source": "question.timeRange",
2876
+ }
2877
+
2878
+
2879
+ def _semantic_time_filter_from_sql_filters(filters: list[dict[str, str]]) -> dict[str, str] | None:
2880
+ start = ""
2881
+ end = ""
2882
+ for item in filters:
2883
+ if str(item.get("source") or "") != "question.timeRange":
2884
+ continue
2885
+ operator = str(item.get("operator") or "")
2886
+ value = str(item.get("value") or "").strip()
2887
+ if operator == ">=":
2888
+ start = value
2889
+ elif operator == "<":
2890
+ end = value
2891
+ if not start and not end:
2892
+ return None
2893
+ value = f"{start} 至 {end}" if start and end else start or end
2894
+ return {
2895
+ "field": "时间范围",
2896
+ "operator": "BETWEEN" if start and end else "=",
2897
+ "value": value,
2898
+ "source": "question.timeRange",
2899
+ }
2900
+
2901
+
2902
+ _TECHNICAL_QUERY_PLAN_FILTER_SOURCES = frozenset({"schema.concepts", "structure.defaultFilters"})
2903
+
2904
+
2905
+ def _semantic_filter_from_sql_filter(item: dict[str, str]) -> dict[str, str] | None:
2906
+ source = str(item.get("source") or "")
2907
+ operator = str(item.get("operator") or "").upper()
2908
+ if source in _TECHNICAL_QUERY_PLAN_FILTER_SOURCES or operator == "DEFAULT":
2909
+ return None
2910
+ if source in {"question.timeRange", "question_analysis.timeRange"}:
2911
+ return None
2912
+ field = str(item.get("field") or "").strip()
2913
+ value = str(item.get("value") or "").strip()
2914
+ if not field or not value:
2915
+ return None
2916
+ label = _query_plan_filter_display_label(field, item)
2917
+ display_value = _query_plan_filter_display_value(value, item)
2918
+ if not label or not display_value:
2919
+ return None
2920
+ display_operator = "=" if operator in {"", "LIKE"} else str(item.get("operator") or "=")
2921
+ semantic = {
2922
+ "field": label,
2923
+ "operator": display_operator,
2924
+ "value": display_value,
2925
+ "source": source or "queryPlan.sqlFilter",
2926
+ }
2927
+ for key in ("description", "logicalGroup", "logicalOperator"):
2928
+ if key in item:
2929
+ semantic[key] = str(item[key])
2930
+ return semantic
2931
+
2932
+
2933
+ def _query_plan_sql_filters(
2934
+ question: str,
2935
+ schema_context: Any,
2936
+ *,
2937
+ searched_entities: dict[str, Any] | None = None,
2938
+ question_entities: list[dict[str, str]] | None = None,
2939
+ schema: dict[str, Any] | None = None,
2940
+ time_range_question: str | None = None,
2941
+ selected_concepts: list[dict[str, Any]] | None = None,
2942
+ analysis: QuestionAnalysis | None = None,
2656
2943
  ) -> list[dict[str, str]]:
2657
2944
  filters: list[dict[str, str]] = []
2658
2945
 
@@ -2787,7 +3074,12 @@ def _apply_query_plan_time_range_filters(
2787
3074
  analysis: QuestionAnalysis | None = None,
2788
3075
  ) -> list[dict[str, str]]:
2789
3076
  """Replace noisy date LIKE filters with explicit range filters defaulting to current year."""
2790
- time_filters = _analysis_time_range_plan_filters(analysis, selected_concepts=selected_concepts)
3077
+ time_filters = _analysis_time_range_plan_filters(
3078
+ analysis,
3079
+ selected_concepts=selected_concepts,
3080
+ question=question,
3081
+ schema=schema,
3082
+ )
2791
3083
  if not time_filters:
2792
3084
  time_filters = time_range_plan_filters(question, schema, concepts=selected_concepts)
2793
3085
  if not time_filters:
@@ -2812,6 +3104,8 @@ def _analysis_time_range_plan_filters(
2812
3104
  analysis: QuestionAnalysis | None,
2813
3105
  *,
2814
3106
  selected_concepts: list[dict[str, Any]] | None = None,
3107
+ question: str | None = None,
3108
+ schema: dict[str, Any] | None = None,
2815
3109
  ) -> list[dict[str, str]]:
2816
3110
  if analysis is None or not isinstance(analysis.time_range, dict):
2817
3111
  return []
@@ -2819,7 +3113,7 @@ def _analysis_time_range_plan_filters(
2819
3113
  end = str(analysis.time_range.get("endExclusive") or analysis.time_range.get("end") or "").strip()
2820
3114
  if not (_looks_like_iso_date(start) and _looks_like_iso_date(end)):
2821
3115
  return []
2822
- field = _time_range_field_from_concepts(selected_concepts)
3116
+ field = _time_range_field_from_question_schema(question, schema) or _time_range_field_from_concepts(selected_concepts)
2823
3117
  if not field:
2824
3118
  return []
2825
3119
  original = str(analysis.time_range.get("originalText") or analysis.time_range.get("value") or "时间范围").strip()
@@ -2841,6 +3135,20 @@ def _analysis_time_range_plan_filters(
2841
3135
  ]
2842
3136
 
2843
3137
 
3138
+ def _time_range_field_from_question_schema(
3139
+ question: str | None,
3140
+ schema: dict[str, Any] | None,
3141
+ ) -> str:
3142
+ if not question or schema is None:
3143
+ return ""
3144
+ filters = time_range_plan_filters(question, schema, concepts=[])
3145
+ for item in filters:
3146
+ field = str(item.get("field") or "").strip()
3147
+ if "." in field:
3148
+ return field
3149
+ return ""
3150
+
3151
+
2844
3152
  def _time_range_field_from_concepts(concepts: list[dict[str, Any]] | None) -> str:
2845
3153
  for concept in concepts or []:
2846
3154
  if not isinstance(concept, dict):
@@ -3533,8 +3841,11 @@ def _query_plan_summary_filters(query_plan: dict[str, Any]) -> list[str]:
3533
3841
  for item in filters:
3534
3842
  if not isinstance(item, dict):
3535
3843
  continue
3536
- field = str(item.get("field") or "")
3844
+ source = str(item.get("source") or "")
3537
3845
  operator = str(item.get("operator") or "")
3846
+ if source in _TECHNICAL_QUERY_PLAN_FILTER_SOURCES or operator.upper() == "DEFAULT":
3847
+ continue
3848
+ field = str(item.get("field") or "")
3538
3849
  value = str(item.get("value") or "")
3539
3850
  label = _query_plan_filter_display_label(field, item)
3540
3851
  display_value = _query_plan_filter_display_value(value, item)
@@ -3723,6 +4034,10 @@ def _parse_execute_unknown_column(error_text: str) -> str | None:
3723
4034
  return match.group(1).strip()
3724
4035
 
3725
4036
 
4037
+ def _is_execute_invalid_group_function(error_text: str) -> bool:
4038
+ return bool(re.search(r"invalid use of group function|ER_INVALID_GROUP_FUNC_USE", error_text, re.IGNORECASE))
4039
+
4040
+
3726
4041
  def _repair_sql_for_local_schema_issues(
3727
4042
  *,
3728
4043
  candidate_sql: str,
@@ -3932,6 +4247,154 @@ def _repair_sql_with_llm_retries(
3932
4247
  return None, last_reason
3933
4248
 
3934
4249
 
4250
+ def _review_sql_with_llm_expert_if_needed(
4251
+ *,
4252
+ generator: SamplingSqlGenerator | None,
4253
+ question: str,
4254
+ sql: str,
4255
+ schema_context: Any,
4256
+ schema: dict[str, Any],
4257
+ query_plan: dict[str, Any] | None,
4258
+ time_range_question: str,
4259
+ default_limit: int,
4260
+ max_limit: int,
4261
+ max_repair_attempts: int,
4262
+ steps: _ExecutionSteps,
4263
+ audit_logger: AuditLogger,
4264
+ session_id: str | None,
4265
+ ) -> tuple[str, str | None]:
4266
+ if generator is None or schema_context is None or not _should_llm_sql_expert_review(sql, query_plan):
4267
+ return sql, None
4268
+ steps.running(8, "LLM SQL 专家复核高风险 SQL 写法。")
4269
+ try:
4270
+ review = generator.review_sql(
4271
+ question=question,
4272
+ sql=sql,
4273
+ schema_context=schema_context,
4274
+ )
4275
+ except SqlGenerationError as exc:
4276
+ audit_logger.log(
4277
+ {
4278
+ "sessionId": session_id,
4279
+ "question": question,
4280
+ "tool": "llm_sql_expert_review",
4281
+ "success": False,
4282
+ "error": str(exc),
4283
+ }
4284
+ )
4285
+ return sql, None
4286
+ audit_logger.log(
4287
+ {
4288
+ "sessionId": session_id,
4289
+ "question": question,
4290
+ "tool": "llm_sql_expert_review",
4291
+ "success": review.executable,
4292
+ "sqlHash": sql_hash(sql),
4293
+ "issues": review.issues,
4294
+ "repaired": bool(review.repaired_sql),
4295
+ }
4296
+ )
4297
+ if review.executable and not review.repaired_sql:
4298
+ steps.done(8, "LLM SQL 专家复核通过。")
4299
+ return sql, None
4300
+ validation_error = {
4301
+ "code": "LLM_SQL_EXPERT_REVIEW_FAILED",
4302
+ "message": _sql_expert_review_message(review.issues),
4303
+ "originalSql": sql,
4304
+ "issues": review.issues,
4305
+ "repairHint": "只修复 SQL 写法问题,不改变业务口径;聚合筛选放 HAVING 或先子查询聚合后外层过滤。",
4306
+ }
4307
+ candidate = review.repaired_sql
4308
+ if candidate:
4309
+ candidate = _finalize_candidate_sql(
4310
+ candidate,
4311
+ question,
4312
+ default_limit=default_limit,
4313
+ max_limit=max_limit,
4314
+ schema=schema,
4315
+ )
4316
+ if query_plan:
4317
+ candidate = apply_time_range_filters_from_plan(candidate, query_plan)
4318
+ candidate = apply_time_range_filters_from_question(candidate, time_range_question, schema)
4319
+ try:
4320
+ enforce_sql_rules(candidate, max_limit=max_limit)
4321
+ _require_no_schema_issues(candidate, schema)
4322
+ steps.done(9, "已根据 LLM SQL 专家复核修复 SQL 写法问题。")
4323
+ steps.done(8, "LLM SQL 专家复核后 SQL 满足本地规则。")
4324
+ return candidate, None
4325
+ except SqlGenerationError as exc:
4326
+ schema_issues = validate_sql_against_schema(candidate, schema)
4327
+ if schema_issues:
4328
+ validation_error = build_schema_validation_error(
4329
+ candidate,
4330
+ schema_issues,
4331
+ schema,
4332
+ max_attempts=max_repair_attempts,
4333
+ )
4334
+ else:
4335
+ validation_error = build_sql_rule_validation_error(
4336
+ candidate,
4337
+ exc,
4338
+ max_limit=max_limit,
4339
+ max_attempts=max_repair_attempts,
4340
+ )
4341
+ validation_error["message"] = f"LLM SQL 专家修复后仍有 SQL 写法问题:{exc}"
4342
+ sql = candidate
4343
+ repaired_sql, fail_reason = _repair_sql_with_llm_retries(
4344
+ generator=generator,
4345
+ question=question,
4346
+ sql=sql,
4347
+ validation_error=validation_error,
4348
+ schema_context=schema_context,
4349
+ audit_logger=audit_logger,
4350
+ session_id=session_id,
4351
+ max_attempts=max_repair_attempts,
4352
+ default_limit=default_limit,
4353
+ max_limit=max_limit,
4354
+ schema=schema,
4355
+ check=lambda candidate_sql: _require_sql_expert_local_pass(candidate_sql, schema, max_limit),
4356
+ steps=steps,
4357
+ running_detail=f"LLM SQL 专家发现写法问题,尝试修复:{validation_error['message']}",
4358
+ failure_detail="LLM SQL 专家发现写法问题,修复失败",
4359
+ )
4360
+ if repaired_sql is None:
4361
+ return sql, fail_reason
4362
+ steps.done(8, "LLM SQL 专家复核后 SQL 满足本地规则。")
4363
+ return repaired_sql, None
4364
+
4365
+
4366
+ def _require_sql_expert_local_pass(sql: str, schema: dict[str, Any], max_limit: int) -> None:
4367
+ enforce_sql_rules(sql, max_limit=max_limit)
4368
+ _require_no_schema_issues(sql, schema)
4369
+
4370
+
4371
+ def _should_llm_sql_expert_review(sql: str, query_plan: dict[str, Any] | None) -> bool:
4372
+ normalized = sql.lower()
4373
+ if re.search(r"\b(group\s+by|having|count|sum|avg|min|max|group_concat)\s*\b", normalized):
4374
+ return True
4375
+ if re.search(r"\(\s*select\b", normalized):
4376
+ return True
4377
+ if isinstance(query_plan, dict):
4378
+ for concept in query_plan.get("matchedConcepts") or []:
4379
+ if isinstance(concept, dict) and str(concept.get("name") or "") in {"filled_job_by_period"}:
4380
+ return True
4381
+ return False
4382
+
4383
+
4384
+ def _sql_expert_review_message(issues: list[dict[str, str]]) -> str:
4385
+ if not issues:
4386
+ return "LLM SQL 专家判断 SQL 可能存在写法问题,需修复后再执行。"
4387
+ parts: list[str] = []
4388
+ for issue in issues:
4389
+ message = str(issue.get("message") or issue.get("code") or "").strip()
4390
+ hint = str(issue.get("repairHint") or "").strip()
4391
+ if message and hint:
4392
+ parts.append(f"{message};建议:{hint}")
4393
+ elif message:
4394
+ parts.append(message)
4395
+ return ";".join(parts) or "LLM SQL 专家判断 SQL 可能存在写法问题,需修复后再执行。"
4396
+
4397
+
3935
4398
  def _query_only_result(reason: str | None = None) -> QueryResult:
3936
4399
  answer = reason.strip() if isinstance(reason, str) and reason.strip() else "查询未能完成,请稍后重试。"
3937
4400
  return QueryResult(
@@ -3991,6 +4454,25 @@ def _low_quality_sql_result(message: str, generated_sql: str) -> QueryResult:
3991
4454
  )
3992
4455
 
3993
4456
 
4457
+ def _query_quality_failure_message(
4458
+ message: str,
4459
+ *,
4460
+ missing_items: list[str] | tuple[str, ...] | None = None,
4461
+ current_problem: str = "",
4462
+ repair_hint: str = "",
4463
+ ) -> str:
4464
+ parts = [f"当前 SQL 不满足查询口径:{message.strip()}"]
4465
+ if missing_items:
4466
+ missing_text = ";".join(str(item) for item in missing_items if str(item).strip())
4467
+ if missing_text:
4468
+ parts.append(f"缺失口径:{missing_text}")
4469
+ if current_problem:
4470
+ parts.append(f"当前 SQL 的问题:{current_problem}")
4471
+ if repair_hint:
4472
+ parts.append(f"建议修复方向:{repair_hint}")
4473
+ return ";".join(parts)
4474
+
4475
+
3994
4476
  def _sensitive_query_only_result(terms: list[str], *, reason: str | None = None) -> QueryResult:
3995
4477
  notice = _sensitive_field_notice(terms)
3996
4478
  if isinstance(reason, str) and reason.strip():