@roll-agent/octopus-agent 0.1.1 → 0.1.3

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.
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import re
5
+ import time
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
7
  from dataclasses import dataclass, field
6
8
  from typing import Any
7
9
 
@@ -93,6 +95,7 @@ class QueryResult:
93
95
  clarification_payload: dict[str, Any] | None = None
94
96
  query_plan: dict[str, Any] | None = None
95
97
  execution_steps: list[dict[str, Any]] = field(default_factory=list)
98
+ timing: dict[str, Any] = field(default_factory=dict)
96
99
  raw_result: dict[str, Any] | None = None
97
100
  result_format: str | None = None
98
101
 
@@ -150,21 +153,24 @@ class SpongeAgent:
150
153
  fast_mode: bool = True,
151
154
  ) -> QueryResult:
152
155
  steps = _ExecutionSteps()
153
- steps.done(1, "已进入 query_sponge")
156
+ steps.running(1)
154
157
  user_question = _isolate_current_question(original_question or question)
155
- schema: dict[str, Any] | None = None
156
- pre_sql_agent_instructions: list[dict[str, Any]] = []
157
- try:
158
- schema = self._ensure_schema()
159
- pre_sql_agent_instructions = _schema_agent_instructions(schema)
160
- except Exception:
161
- schema = None
162
- pre_sql_agent_instructions = []
163
- # Step 2: LLM 需求分析
158
+ steps.done(1, "已进入 query_sponge")
159
+ # Step 2: 确认 schema 与最新版本一致(分析前必须完成,供 agentInstructions 使用)
164
160
  steps.running(2)
161
+ try:
162
+ schema, schema_ensure_detail = self._ensure_schema()
163
+ except Exception as exc:
164
+ reason = f"{type(exc).__name__}: {exc}"
165
+ steps.error(2, reason)
166
+ return _attach_execution_steps(_query_only_result(reason), steps)
167
+ pre_sql_agent_instructions = _schema_agent_instructions(schema)
168
+ steps.done(2, schema_ensure_detail or f"schemaVersion={schema.get('schemaVersion') or 'unknown'}")
169
+ # Step 3: LLM 需求分析
170
+ steps.running(3)
165
171
  if sampler is None:
166
172
  reason = "当前无模型可用,请确认配置文件。"
167
- steps.error(2, reason)
173
+ steps.error(3, reason)
168
174
  return _attach_execution_steps(_query_only_result(reason), steps)
169
175
  try:
170
176
  analysis = analyze_question(
@@ -174,15 +180,15 @@ class SpongeAgent:
174
180
  )
175
181
  except QuestionAnalysisError as exc:
176
182
  reason = f"需求分析失败:{exc}"
177
- steps.error(2, reason)
183
+ steps.error(3, reason)
178
184
  return _attach_execution_steps(_query_only_result(reason), steps)
179
185
  if analysis.intent == "mutation":
180
186
  reason = f"检测到写操作意图(置信度 {analysis.confidence:.0%});query_sponge 只支持只读查询。"
181
- steps.error(2, reason)
187
+ steps.error(3, reason)
182
188
  return _attach_execution_steps(_non_query_result(reason), steps)
183
189
  if analysis.intent == "non_query":
184
190
  reason = analysis.clarification_reason or "请先输入明确的业务数据查询需求。"
185
- steps.error(2, reason)
191
+ steps.error(3, reason)
186
192
  return _attach_execution_steps(_non_query_result(reason), steps)
187
193
  # intent == "query"
188
194
  sql_question = _preserve_limit_scope_in_normalized_question(
@@ -191,7 +197,7 @@ class SpongeAgent:
191
197
  )
192
198
  time_range_question = _time_range_source_question(user_question, sql_question)
193
199
  steps.done(
194
- 2,
200
+ 3,
195
201
  f"意图=query({analysis.confidence:.0%});"
196
202
  f"对象={analysis.target_entities or '未提取'};"
197
203
  f"规范化问题={sql_question}",
@@ -215,15 +221,6 @@ class SpongeAgent:
215
221
  session_id=session_id or new_session_id(),
216
222
  )
217
223
  sensitive_terms = sensitive_terms_in_text(sql_question)
218
- steps.running(3)
219
- if schema is None:
220
- try:
221
- schema = self._ensure_schema()
222
- except Exception as exc:
223
- reason = f"{type(exc).__name__}: {exc}"
224
- steps.error(3, reason)
225
- return _attach_execution_steps(_query_only_result(reason), steps)
226
- steps.done(3, f"schemaVersion={schema.get('schemaVersion') or 'unknown'}")
227
224
  required_concept_gap = _required_schema_concepts_gap_message(analysis, schema)
228
225
  if required_concept_gap is not None:
229
226
  steps.error(4, required_concept_gap)
@@ -237,49 +234,70 @@ class SpongeAgent:
237
234
  steps.error(4, "业务指标口径不明确,需要用户先确认指标、时间、状态和聚合维度。")
238
235
  return _attach_execution_steps(_business_metric_clarification_result(business_clarification), steps)
239
236
  repaired_sql: str | None = None
237
+ # Step 4: schemaContext 匹配与实体检索并行
238
+ # 实体检索先复用步骤 3 的候选并发起 search_entities;schemaContext 本地匹配同时进行。
239
+ # schemaContext 完成后补齐仅依赖匹配结果的 schema 路径候选。
240
240
  steps.running(4)
241
- try:
242
- schema_context = retrieve_schema_context(
243
- schema,
241
+
242
+ def _match_schema_context() -> Any:
243
+ return retrieve_schema_context(schema, sql_question)
244
+
245
+ def _resolve_entities_from_analysis() -> dict[str, Any] | None:
246
+ return self._search_entities_for_question(
247
+ context,
244
248
  sql_question,
249
+ None,
250
+ schema,
251
+ sampler=sampler,
252
+ analysis=analysis,
253
+ agent_instructions=pre_sql_agent_instructions,
245
254
  )
246
- except Exception as exc:
247
- reason = f"{type(exc).__name__}: {exc}"
248
- steps.error(4, reason)
249
- return _attach_execution_steps(_query_only_result(reason), steps)
250
- steps.done(4, _schema_context_step_detail(schema_context))
251
- if not _schema_context_has_semantic_match(sql_question, schema_context):
252
- reason = "schemaContext 未匹配到可用表、字段、指标、概念或示例。"
253
- steps.error(4, reason)
254
- return _attach_execution_steps(_schema_no_match_result(sql_question), steps)
255
- steps.running(5)
256
- searched_entities = self._search_entities_for_question(
257
- context,
255
+
256
+ with ThreadPoolExecutor(max_workers=2) as executor:
257
+ future_context = executor.submit(_match_schema_context)
258
+ future_entities = executor.submit(_resolve_entities_from_analysis)
259
+ try:
260
+ schema_context = future_context.result()
261
+ except Exception as exc:
262
+ reason = f"{type(exc).__name__}: {exc}"
263
+ steps.error(4, reason)
264
+ return _attach_execution_steps(_query_only_result(reason), steps)
265
+ if not _schema_context_has_semantic_match(sql_question, schema_context):
266
+ reason = "schemaContext 未匹配到可用表、字段、指标、概念或示例。"
267
+ steps.error(4, reason)
268
+ return _attach_execution_steps(_schema_no_match_result(sql_question), steps)
269
+ try:
270
+ searched_entities = future_entities.result()
271
+ except Exception as exc:
272
+ reason = f"{type(exc).__name__}: {exc}"
273
+ steps.error(4, reason)
274
+ return _attach_execution_steps(_query_only_result(reason), steps)
275
+
276
+ searched_entities = _merge_schema_context_entity_candidates(
277
+ searched_entities,
258
278
  sql_question,
259
279
  schema_context,
260
- schema,
261
- sampler=sampler,
262
280
  analysis=analysis,
263
281
  )
264
- steps.done(5, _entity_search_step_detail(searched_entities))
265
- steps.running(6)
282
+ steps.done(
283
+ 4,
284
+ f"{_schema_context_step_detail(schema_context)};{_entity_search_step_detail(searched_entities)}",
285
+ )
286
+ steps.running(5)
266
287
  query_plan = _build_query_plan(
267
288
  sql_question,
268
289
  schema_context,
269
290
  schema,
270
291
  searched_entities=searched_entities,
271
- sampler=sampler,
272
292
  time_range_question=time_range_question,
273
293
  analysis=analysis,
274
294
  )
275
- steps.done(6, "已生成查询计划并直接执行。")
276
295
  sql_schema_context = (
277
296
  narrow_schema_context_for_query_plan(schema_context, query_plan, full_schema=schema)
278
297
  if query_plan
279
298
  else schema_context
280
299
  )
281
300
  generator: SamplingSqlGenerator | None = None
282
- steps.running(7)
283
301
  if sampler is None:
284
302
  generated = _generate_from_schema_example(
285
303
  question=sql_question,
@@ -297,9 +315,9 @@ class SpongeAgent:
297
315
  )
298
316
  if generated is None:
299
317
  reason = "未提供 sampler,且 schema examples 没有匹配到可复用 SQL。"
300
- steps.error(7, reason)
318
+ steps.error(5, reason)
301
319
  return _attach_execution_steps(_query_only_result(reason), steps)
302
- steps.done(7, "已从 schema examples 匹配 SQL。")
320
+ steps.done(5, "已生成查询计划并从 schema examples 匹配 SQL。")
303
321
  else:
304
322
  generator = SamplingSqlGenerator(
305
323
  sampler=sampler,
@@ -322,12 +340,12 @@ class SpongeAgent:
322
340
  query_plan=query_plan,
323
341
  )
324
342
  if generation_clarification is not None:
325
- steps.waiting(7, "LLM 判断需要用户补充信息。")
343
+ steps.waiting(5, "LLM 判断需要用户补充信息。")
326
344
  return _attach_execution_steps(generation_clarification, steps)
327
345
  if generated.sql == "" and repaired_sql is None:
328
346
  if generation_repair_attempted:
329
347
  reason = "LLM SQL 生成失败,自动修复后仍未返回可用 SELECT SQL。"
330
- steps.error(7, reason)
348
+ steps.error(5, reason)
331
349
  return _attach_execution_steps(_query_only_result(reason), steps)
332
350
  repaired_sql, fail_reason = _repair_sql_with_llm_retries(
333
351
  generator=generator,
@@ -351,9 +369,9 @@ class SpongeAgent:
351
369
  failure_detail="LLM 返回空 SQL,修复失败",
352
370
  )
353
371
  if repaired_sql is None:
354
- steps.error(9, fail_reason)
372
+ steps.error(7, fail_reason)
355
373
  return _attach_execution_steps(_query_only_result(fail_reason), steps)
356
- steps.done(7, "已通过 LLM 生成 SQL。")
374
+ steps.done(5, "已生成查询计划并通过 LLM 生成 SQL。")
357
375
  candidate_sql = repaired_sql or _finalize_candidate_sql(
358
376
  generated.sql,
359
377
  sql_question,
@@ -374,7 +392,7 @@ class SpongeAgent:
374
392
  )
375
393
  if sampler is None or schema_context is None or generator is None:
376
394
  reason = f"{quality_message};当前没有 sampler 可修复。"
377
- steps.error(8, reason)
395
+ steps.error(6, reason)
378
396
  return _attach_execution_steps(_query_only_result(reason), steps)
379
397
  repaired_sql, fail_reason = _repair_sql_with_llm_retries(
380
398
  generator=generator,
@@ -401,22 +419,26 @@ class SpongeAgent:
401
419
  failure_detail=f"SQL 口径校验失败,修复失败:{quality_message}",
402
420
  )
403
421
  if repaired_sql is None:
404
- steps.error(9, fail_reason)
422
+ steps.error(7, fail_reason)
405
423
  return _attach_execution_steps(_query_only_result(fail_reason), steps)
406
424
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
407
425
  candidate_sql = repaired_sql
408
- steps.done(9, "已补充时间范围过滤。")
426
+ steps.done(7, "已补充时间范围过滤。")
409
427
  if query_plan and not sql_satisfies_query_plan_filters(candidate_sql, query_plan):
410
428
  missing_filters = sql_missing_query_plan_filters(candidate_sql, query_plan)
411
429
  quality_message = _query_quality_failure_message(
412
430
  f"SQL 缺少查询计划中的筛选条件:{'; '.join(missing_filters)}。",
413
431
  missing_items=missing_filters,
414
432
  current_problem="当前 SQL 没有覆盖用户已确认的关键筛选,执行后可能返回范围过宽或不相关的数据。",
415
- repair_hint="必须 JOIN 相关表,并把缺失的 table.column 过滤写入 WHERE;同组 OR 条件需要用括号包裹。",
433
+ repair_hint=(
434
+ "必须 JOIN 相关表,并把缺失的 table.column 过滤写入 WHERE;"
435
+ "同组 OR 条件需要用括号包裹;"
436
+ "不同地点维度的 city.name 与 region.name(如苏州与昆山市)必须用 AND,禁止写成 OR。"
437
+ ),
416
438
  )
417
439
  if sampler is None or schema_context is None or generator is None:
418
440
  reason = quality_message
419
- steps.error(8, reason)
441
+ steps.error(6, reason)
420
442
  return _attach_execution_steps(_query_only_result(reason), steps)
421
443
  repaired_sql, fail_reason = _repair_sql_with_llm_retries(
422
444
  generator=generator,
@@ -428,8 +450,11 @@ class SpongeAgent:
428
450
  "originalSql": candidate_sql,
429
451
  "missingFilters": missing_filters,
430
452
  "missingItems": missing_filters,
431
- "currentProblem": "当前 SQL 没有覆盖用户已确认的关键筛选。",
432
- "repairHint": "必须 JOIN 相关表,并把缺失的 table.column 过滤写入 WHERE。",
453
+ "currentProblem": "当前 SQL 没有覆盖用户已确认的关键筛选,或把层级地点条件错误写成了 OR。",
454
+ "repairHint": (
455
+ "必须 JOIN 相关表,并把缺失的 table.column 过滤写入 WHERE;"
456
+ "city.name 与 region.name 若表示地级市+下级区县,必须 AND。"
457
+ ),
433
458
  },
434
459
  schema_context=sql_schema_context,
435
460
  audit_logger=self.audit_logger,
@@ -444,11 +469,11 @@ class SpongeAgent:
444
469
  failure_detail=f"SQL 口径校验失败,修复失败:{quality_message}",
445
470
  )
446
471
  if repaired_sql is None:
447
- steps.error(9, fail_reason)
472
+ steps.error(7, fail_reason)
448
473
  return _attach_execution_steps(_query_only_result(fail_reason), steps)
449
474
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
450
475
  candidate_sql = repaired_sql
451
- steps.done(9, "已补充查询计划筛选条件。")
476
+ steps.done(7, "已补充查询计划筛选条件。")
452
477
  if query_plan and not sql_satisfies_query_plan_select_fields(candidate_sql, query_plan):
453
478
  missing_select_fields = sql_missing_query_plan_select_fields(candidate_sql, query_plan)
454
479
  quality_message = _query_quality_failure_message(
@@ -459,7 +484,7 @@ class SpongeAgent:
459
484
  )
460
485
  if sampler is None or schema_context is None or generator is None:
461
486
  reason = quality_message
462
- steps.error(8, reason)
487
+ steps.error(6, reason)
463
488
  return _attach_execution_steps(_query_only_result(reason), steps)
464
489
  repaired_sql, fail_reason = _repair_sql_with_llm_retries(
465
490
  generator=generator,
@@ -487,16 +512,16 @@ class SpongeAgent:
487
512
  failure_detail=f"SQL 口径校验失败,修复失败:{quality_message}",
488
513
  )
489
514
  if repaired_sql is None:
490
- steps.error(9, fail_reason)
515
+ steps.error(7, fail_reason)
491
516
  return _attach_execution_steps(_query_only_result(fail_reason), steps)
492
517
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
493
518
  candidate_sql = repaired_sql
494
- steps.done(9, "已补充查询计划展示字段。")
519
+ steps.done(7, "已补充查询计划展示字段。")
495
520
  low_quality_issue = low_quality_sql_issue_for_question(sql_question, candidate_sql, schema=schema)
496
521
  if low_quality_issue is not None:
497
522
  quality_message = format_sql_quality_issue(low_quality_issue)
498
523
  if sampler is None or schema_context is None or generator is None:
499
- steps.error(8, quality_message)
524
+ steps.error(6, quality_message)
500
525
  return _attach_execution_steps(_low_quality_sql_result(quality_message, candidate_sql), steps)
501
526
  repaired_sql, fail_reason = _repair_sql_with_llm_retries(
502
527
  generator=generator,
@@ -523,11 +548,11 @@ class SpongeAgent:
523
548
  failure_detail=f"SQL 口径校验失败,修复失败:{quality_message}",
524
549
  )
525
550
  if repaired_sql is None:
526
- steps.error(9, fail_reason)
551
+ steps.error(7, fail_reason)
527
552
  return _attach_execution_steps(_low_quality_sql_result(fail_reason, candidate_sql), steps)
528
553
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
529
554
  candidate_sql = repaired_sql
530
- steps.done(9, "已修复 SQL 质量问题。")
555
+ steps.done(7, "已修复 SQL 质量问题。")
531
556
  prior_sql = candidate_sql
532
557
  candidate_sql, schema_fail = _repair_sql_for_local_schema_issues(
533
558
  candidate_sql=candidate_sql,
@@ -544,7 +569,7 @@ class SpongeAgent:
544
569
  steps=steps,
545
570
  )
546
571
  if schema_fail:
547
- steps.error(8, schema_fail)
572
+ steps.error(6, schema_fail)
548
573
  return _attach_execution_steps(_query_only_result(schema_fail), steps)
549
574
  if candidate_sql != prior_sql:
550
575
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
@@ -556,13 +581,13 @@ class SpongeAgent:
556
581
  schema=schema,
557
582
  )
558
583
  try:
559
- steps.running(8)
584
+ steps.running(6)
560
585
  enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
561
- steps.done(8, "SQL 满足只读、安全、LIMIT、中文别名等规则。")
586
+ steps.done(6, "SQL 满足只读、安全、LIMIT、中文别名等规则。")
562
587
  except SqlGenerationError as exc:
563
588
  if sampler is None or schema_context is None or generator is None:
564
589
  reason = f"SQL 违反本地规则,且当前没有 sampler 可修复:{exc}"
565
- steps.error(8, reason)
590
+ steps.error(6, reason)
566
591
  return _attach_execution_steps(_query_only_result(reason), steps)
567
592
  if _is_sensitive_sql_error(exc) and sensitive_terms:
568
593
  validation_error = {
@@ -602,15 +627,14 @@ class SpongeAgent:
602
627
  failure_detail=failure_detail,
603
628
  )
604
629
  if repaired_sql is None:
605
- steps.error(9, fail_reason)
630
+ steps.error(7, fail_reason)
606
631
  if _is_sensitive_sql_error(exc) and sensitive_terms:
607
632
  return _attach_execution_steps(_sensitive_query_only_result(sensitive_terms, reason=fail_reason), steps)
608
633
  return _attach_execution_steps(_query_only_result(fail_reason), steps)
609
634
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
610
635
  candidate_sql = repaired_sql
611
- steps.done(8, "SQL 修复后满足本地规则。")
612
- if not any(step["status"] == "done" and step["step"] == 9 for step in steps.steps):
613
- steps.skipped(9, "SQL 无需修复。")
636
+ steps.done(6, "SQL 修复后满足本地规则。")
637
+ _skip_repair_step_if_pending(steps, "SQL 无需修复。")
614
638
  prior_sql = candidate_sql
615
639
  candidate_sql, schema_fail = _repair_sql_for_local_schema_issues(
616
640
  candidate_sql=candidate_sql,
@@ -629,7 +653,7 @@ class SpongeAgent:
629
653
  running_prefix="validate_sql 前本地 schema 校验失败,尝试修复",
630
654
  )
631
655
  if schema_fail:
632
- steps.error(10, schema_fail)
656
+ steps.error(8, schema_fail)
633
657
  return _attach_execution_steps(_query_only_result(schema_fail), steps)
634
658
  if candidate_sql != prior_sql:
635
659
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
@@ -656,7 +680,7 @@ class SpongeAgent:
656
680
  running_prefix="补充时间范围后本地 schema 校验失败,尝试修复",
657
681
  )
658
682
  if schema_fail:
659
- steps.error(10, schema_fail)
683
+ steps.error(8, schema_fail)
660
684
  return _attach_execution_steps(_query_only_result(schema_fail), steps)
661
685
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
662
686
  candidate_sql = _remove_noise_name_like_filters_from_sql(candidate_sql, analysis=analysis)
@@ -678,23 +702,24 @@ class SpongeAgent:
678
702
  steps=steps,
679
703
  audit_logger=self.audit_logger,
680
704
  session_id=context.session_id,
705
+ enable_expert_review=not fast_mode,
681
706
  )
682
707
  if expert_fail:
683
- steps.error(10, expert_fail)
708
+ steps.error(8, expert_fail)
684
709
  return _attach_execution_steps(_query_only_result(expert_fail), steps)
685
710
  if candidate_sql != prior_sql:
686
711
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
687
712
  repaired_sql = candidate_sql
688
- steps.running(10)
713
+ steps.running(8)
689
714
  try:
690
715
  normalized_sql = self._validate_sql(context, user_question, candidate_sql)
691
716
  except Exception as exc:
692
717
  reason = f"{type(exc).__name__}: {exc}"
693
- steps.error(10, reason)
718
+ steps.error(8, reason)
694
719
  return _attach_execution_steps(_query_only_result(reason), steps)
695
720
  if normalized_sql is None and _is_auth_validation_error(self._validation_error_code()):
696
721
  reason = _validation_error_message(self._last_validation_result) or "MCP 用户认证失败。"
697
- steps.error(10, reason)
722
+ steps.error(8, reason)
698
723
  return _attach_execution_steps(
699
724
  _auth_error_result(
700
725
  self._last_validation_result,
@@ -708,7 +733,7 @@ class SpongeAgent:
708
733
  # the current MCP contract returns an empty success result for no-scope
709
734
  # users, which the normal flow already renders. Older deployments still
710
735
  # emit these codes, so treat them as an empty result instead of raising.
711
- steps.done(10, "validate_sql 返回旧版空权限错误,按空结果兼容处理。")
736
+ steps.done(8, "validate_sql 返回旧版空权限错误,按空结果兼容处理。")
712
737
  return _attach_execution_steps(_empty_scope_result(user_question, result_format), steps)
713
738
  if sampler is not None and normalized_sql is None and schema_context is not None and generator is not None:
714
739
  current_sql = candidate_sql
@@ -717,7 +742,7 @@ class SpongeAgent:
717
742
  validate_message = _validation_error_message(self._last_validation_result)
718
743
  last_reason = f"validate_sql 失败,修复失败:{validate_message}"
719
744
  for attempt in range(1, self.config.sql.max_repair_attempts + 1):
720
- steps.running(9, f"validate_sql 失败,尝试修复:{validate_message}(第 {attempt}/{self.config.sql.max_repair_attempts} 次)")
745
+ steps.running(7, f"validate_sql 失败,尝试修复:{validate_message}(第 {attempt}/{self.config.sql.max_repair_attempts} 次)")
721
746
  repair_error = {
722
747
  **validation_error,
723
748
  "attempt": attempt,
@@ -771,7 +796,7 @@ class SpongeAgent:
771
796
  except SqlGenerationError as exc:
772
797
  if _is_sensitive_sql_error(exc) and sensitive_terms:
773
798
  reason = f"validate_sql 修复后包含敏感字段:{exc}"
774
- steps.error(9, reason)
799
+ steps.error(7, reason)
775
800
  return _attach_execution_steps(_sensitive_query_only_result(sensitive_terms, reason=reason), steps)
776
801
  last_reason = f"validate_sql 修复后违反本地规则:{exc}"
777
802
  validation_error = build_sql_rule_validation_error(
@@ -802,15 +827,15 @@ class SpongeAgent:
802
827
  normalized_sql = self._validate_sql(context, user_question, repaired_sql)
803
828
  except Exception as exc:
804
829
  reason = f"{type(exc).__name__}: {exc}"
805
- steps.error(10, reason)
830
+ steps.error(8, reason)
806
831
  return _attach_execution_steps(_query_only_result(reason), steps)
807
832
  if normalized_sql is not None:
808
833
  candidate_sql = repaired_sql
809
- steps.done(9, f"已根据 validate_sql 错误修复 SQL(第 {attempt}/{self.config.sql.max_repair_attempts} 次)。")
834
+ steps.done(7, f"已根据 validate_sql 错误修复 SQL(第 {attempt}/{self.config.sql.max_repair_attempts} 次)。")
810
835
  break
811
836
  if _is_auth_validation_error(self._validation_error_code()):
812
837
  reason = _validation_error_message(self._last_validation_result) or "MCP 用户认证失败。"
813
- steps.error(10, reason)
838
+ steps.error(8, reason)
814
839
  return _attach_execution_steps(
815
840
  _auth_error_result(
816
841
  self._last_validation_result,
@@ -825,15 +850,15 @@ class SpongeAgent:
825
850
  validation_error["originalSql"] = repaired_sql
826
851
  current_sql = repaired_sql
827
852
  else:
828
- steps.error(9, last_reason)
829
- steps.error(10, last_reason)
853
+ steps.error(7, last_reason)
854
+ steps.error(8, last_reason)
830
855
  return _attach_execution_steps(_query_only_result(last_reason), steps)
831
856
  if normalized_sql is None:
832
857
  reason = f"SQL validation failed: {_validation_error_message(self._last_validation_result)}"
833
- steps.error(10, reason)
858
+ steps.error(8, reason)
834
859
  return _attach_execution_steps(_query_only_result(reason), steps)
835
- steps.done(10, "validate_sql 通过。")
836
- steps.running(11)
860
+ steps.done(8, "validate_sql 通过。")
861
+ steps.running(9)
837
862
  try:
838
863
  result = self._execute_sql(context, user_question, normalized_sql)
839
864
  except Exception as exc:
@@ -868,7 +893,7 @@ class SpongeAgent:
868
893
  )
869
894
  if repaired_exec_sql is None:
870
895
  reason = fail_reason or f"{type(exc).__name__}: {exc}"
871
- steps.error(11, reason)
896
+ steps.error(9, reason)
872
897
  return _attach_execution_steps(_query_only_result(reason), steps)
873
898
  if query_plan:
874
899
  repaired_exec_sql = apply_time_range_filters_from_plan(repaired_exec_sql, query_plan)
@@ -879,23 +904,23 @@ class SpongeAgent:
879
904
  revalidated = self._validate_sql(context, user_question, repaired_exec_sql)
880
905
  except Exception as validate_exc:
881
906
  reason = f"{type(validate_exc).__name__}: {validate_exc}"
882
- steps.error(10, reason)
907
+ steps.error(8, reason)
883
908
  return _attach_execution_steps(_query_only_result(reason), steps)
884
909
  if revalidated is None:
885
910
  reason = (
886
911
  f"execute 聚合函数用法修复后 validate_sql 失败:"
887
912
  f"{_validation_error_message(self._last_validation_result)}"
888
913
  )
889
- steps.error(10, reason)
914
+ steps.error(8, reason)
890
915
  return _attach_execution_steps(_query_only_result(reason), steps)
891
916
  try:
892
917
  result = self._execute_sql(context, user_question, revalidated)
893
918
  normalized_sql = revalidated
894
919
  repaired_sql = repaired_exec_sql
895
- steps.done(11, f"execute_sql 完成,rowCount={result.get('rowCount')}")
920
+ steps.done(9, f"execute_sql 完成,rowCount={result.get('rowCount')}")
896
921
  except Exception as retry_exc:
897
922
  reason = f"{type(retry_exc).__name__}: {retry_exc}"
898
- steps.error(11, reason)
923
+ steps.error(9, reason)
899
924
  return _attach_execution_steps(_query_only_result(reason), steps)
900
925
  elif (
901
926
  unknown_col
@@ -951,43 +976,43 @@ class SpongeAgent:
951
976
  running_prefix="execute 修复补充时间范围后本地 schema 校验失败,尝试修复",
952
977
  )
953
978
  if schema_fail:
954
- steps.error(10, schema_fail)
979
+ steps.error(8, schema_fail)
955
980
  return _attach_execution_steps(_query_only_result(schema_fail), steps)
956
981
  try:
957
982
  revalidated = self._validate_sql(context, user_question, repaired_exec_sql)
958
983
  except Exception as validate_exc:
959
984
  reason = f"{type(validate_exc).__name__}: {validate_exc}"
960
- steps.error(10, reason)
985
+ steps.error(8, reason)
961
986
  return _attach_execution_steps(_query_only_result(reason), steps)
962
987
  if revalidated is not None:
963
988
  try:
964
989
  result = self._execute_sql(context, user_question, revalidated)
965
990
  normalized_sql = revalidated
966
991
  repaired_sql = repaired_exec_sql
967
- steps.done(11, f"execute_sql 完成,rowCount={result.get('rowCount')}")
992
+ steps.done(9, f"execute_sql 完成,rowCount={result.get('rowCount')}")
968
993
  except Exception as retry_exc:
969
994
  reason = f"{type(retry_exc).__name__}: {retry_exc}"
970
- steps.error(11, reason)
995
+ steps.error(9, reason)
971
996
  return _attach_execution_steps(_query_only_result(reason), steps)
972
997
  else:
973
998
  reason = (
974
999
  f"execute 未知列修复后 validate_sql 失败:"
975
1000
  f"{_validation_error_message(self._last_validation_result)}"
976
1001
  )
977
- steps.error(10, reason)
1002
+ steps.error(8, reason)
978
1003
  return _attach_execution_steps(_query_only_result(reason), steps)
979
1004
  else:
980
1005
  reason = fail_reason or f"{type(exc).__name__}: {exc}"
981
- steps.error(11, reason)
1006
+ steps.error(9, reason)
982
1007
  return _attach_execution_steps(_query_only_result(reason), steps)
983
1008
  else:
984
1009
  reason = f"{type(exc).__name__}: {exc}"
985
- steps.error(11, reason)
1010
+ steps.error(9, reason)
986
1011
  return _attach_execution_steps(_query_only_result(reason), steps)
987
1012
  else:
988
- steps.done(11, f"execute_sql 完成,rowCount={result.get('rowCount')}")
1013
+ steps.done(9, f"execute_sql 完成,rowCount={result.get('rowCount')}")
989
1014
  result = self._ensure_limited_flag(result)
990
- steps.running(12)
1015
+ steps.running(10)
991
1016
  try:
992
1017
  if sampler is not None:
993
1018
  answer = render_result_with_llm(
@@ -1001,7 +1026,7 @@ class SpongeAgent:
1001
1026
  answer = render_result(user_question, result, result_format)
1002
1027
  except Exception as exc:
1003
1028
  reason = f"{type(exc).__name__}: {exc}"
1004
- steps.error(12, reason)
1029
+ steps.error(10, reason)
1005
1030
  return _attach_execution_steps(_query_only_result(reason), steps)
1006
1031
  if query_plan:
1007
1032
  answer = ensure_query_plan_summary_answer(answer, user_question, result_format, query_plan)
@@ -1031,8 +1056,8 @@ class SpongeAgent:
1031
1056
  result_format,
1032
1057
  _sensitive_field_notice(sensitive_terms),
1033
1058
  )
1034
- steps.done(12, "LLM 已渲染结果并返回。" if sampler is not None else "结果已渲染并返回。")
1035
- return _attach_execution_steps(QueryResult(
1059
+ steps.done(10, "结果已渲染并返回。")
1060
+ result_with_steps = _attach_execution_steps(QueryResult(
1036
1061
  answer=answer,
1037
1062
  generated_sql=generated.sql,
1038
1063
  normalized_sql=normalized_sql,
@@ -1042,55 +1067,98 @@ class SpongeAgent:
1042
1067
  raw_result=result,
1043
1068
  result_format=result_format,
1044
1069
  ), steps)
1070
+ self.audit_logger.log(
1071
+ {
1072
+ "sessionId": context.session_id,
1073
+ "question": user_question,
1074
+ "tool": "query_timing",
1075
+ "timing": result_with_steps.timing,
1076
+ }
1077
+ )
1078
+ return result_with_steps
1045
1079
 
1046
- def _ensure_schema(self) -> dict[str, Any]:
1080
+ def _ensure_schema(self) -> tuple[dict[str, Any], str]:
1081
+ """Load schema only when local cache is confirmed to match the latest version.
1082
+
1083
+ Usable means either:
1084
+ - ``get_schema`` returns ``changed=false`` for the cached ``schemaVersion``, or
1085
+ - ``get_schema`` returns a fresh payload with ``tables`` that is saved as the new cache.
1086
+
1087
+ Stale local cache is never used when the latest version cannot be confirmed.
1088
+ """
1047
1089
  cached_schema = self.schema_cache.load()
1048
- cached_version = str(cached_schema.get("schemaVersion")) if cached_schema and cached_schema.get("schemaVersion") else None
1090
+ cached_version = (
1091
+ str(cached_schema.get("schemaVersion"))
1092
+ if cached_schema and cached_schema.get("schemaVersion")
1093
+ else None
1094
+ )
1049
1095
  trace_id = new_trace_id("trace_schema")
1050
1096
  try:
1051
1097
  schema_response = self.mcp_client.get_schema(
1052
1098
  trace_id=trace_id,
1053
1099
  schema_version=cached_version,
1054
1100
  )
1055
- except Exception:
1056
- if cached_schema is not None:
1057
- self.audit_logger.log(
1058
- {
1059
- "traceId": trace_id,
1060
- "tool": "get_schema",
1061
- "schemaVersion": cached_version,
1062
- "fallback": "cached_schema",
1063
- }
1101
+ except Exception as exc:
1102
+ self.audit_logger.log(
1103
+ {
1104
+ "traceId": trace_id,
1105
+ "tool": "get_schema",
1106
+ "schemaVersion": cached_version,
1107
+ "versionConfirmed": False,
1108
+ "error": f"{type(exc).__name__}: {exc}",
1109
+ }
1110
+ )
1111
+ raise RuntimeError(
1112
+ "无法确认本地 schema 与最新版本一致:"
1113
+ f"get_schema 失败({type(exc).__name__}: {exc})"
1114
+ ) from exc
1115
+
1116
+ if schema_response.get("changed") is False:
1117
+ if cached_schema is None or not cached_version:
1118
+ raise RuntimeError(
1119
+ "get_schema 返回 changed=false,但本地没有带 schemaVersion 的缓存,无法确认版本一致"
1120
+ )
1121
+ remote_version = schema_response.get("schemaVersion")
1122
+ if remote_version is not None and str(remote_version) != cached_version:
1123
+ raise RuntimeError(
1124
+ "本地 schemaVersion 与最新版本不一致:"
1125
+ f"cache={cached_version}, latest={remote_version}"
1064
1126
  )
1065
- return cached_schema
1066
- raise
1067
-
1068
- if schema_response.get("changed") is False and cached_schema is not None:
1069
1127
  self.audit_logger.log(
1070
1128
  {
1071
1129
  "traceId": trace_id,
1072
1130
  "tool": "get_schema",
1073
1131
  "schemaVersion": cached_version,
1074
1132
  "changed": False,
1133
+ "versionConfirmed": True,
1134
+ "source": "cache",
1075
1135
  }
1076
1136
  )
1077
- return cached_schema
1137
+ detail = f"已确认与最新版本一致(缓存 schemaVersion={cached_version})"
1138
+ return cached_schema, detail
1078
1139
 
1079
1140
  if "tables" in schema_response:
1141
+ latest_version = schema_response.get("schemaVersion")
1142
+ if latest_version is None or str(latest_version).strip() == "":
1143
+ raise RuntimeError("get_schema 返回了 tables,但缺少 schemaVersion,无法确认已同步到最新版本")
1080
1144
  self.schema_cache.save(schema_response)
1081
1145
  self.audit_logger.log(
1082
1146
  {
1083
1147
  "traceId": trace_id,
1084
1148
  "tool": "get_schema",
1085
- "schemaVersion": schema_response.get("schemaVersion"),
1149
+ "schemaVersion": latest_version,
1086
1150
  "changed": schema_response.get("changed"),
1151
+ "versionConfirmed": True,
1152
+ "source": "response",
1087
1153
  }
1088
1154
  )
1089
- return schema_response
1155
+ detail = f"已刷新到最新版本(schemaVersion={latest_version})"
1156
+ return schema_response, detail
1090
1157
 
1091
- if cached_schema is not None:
1092
- return cached_schema
1093
- raise RuntimeError("Sponge get_schema did not return tables and no local schema cache exists")
1158
+ raise RuntimeError(
1159
+ "无法确认本地 schema 与最新版本一致:"
1160
+ "get_schema 既未返回 changed=false,也未返回可刷新的 tables"
1161
+ )
1094
1162
 
1095
1163
  def _search_entities_for_question(
1096
1164
  self,
@@ -1101,35 +1169,68 @@ class SpongeAgent:
1101
1169
  *,
1102
1170
  sampler: Sampler | None = None,
1103
1171
  analysis: QuestionAnalysis | None = None,
1172
+ agent_instructions: list[dict[str, Any]] | None = None,
1104
1173
  ) -> dict[str, Any] | None:
1105
- search_candidates, schema_candidates = _collect_entity_resolution_candidates(
1174
+ instructions = agent_instructions
1175
+ if instructions is None and schema_context is not None:
1176
+ instructions = getattr(schema_context, "agent_instructions", []) or []
1177
+ search_candidates, schema_candidates, question_entities = _collect_entity_resolution_candidates(
1106
1178
  question,
1107
1179
  schema,
1108
1180
  schema_context,
1109
1181
  sampler,
1110
- agent_instructions=getattr(schema_context, "agent_instructions", []) or [],
1182
+ agent_instructions=instructions,
1111
1183
  analysis=analysis,
1112
1184
  )
1113
1185
  if not search_candidates and not schema_candidates:
1114
- return None
1186
+ # Extraction already ran; keep the flat list for query plan reuse even
1187
+ # when nothing needs remote search_entities confirmation.
1188
+ return {
1189
+ "traceId": "",
1190
+ "searchedName": "",
1191
+ "entityCandidates": [],
1192
+ "schemaCandidates": [],
1193
+ "searchResults": [],
1194
+ "entities": {},
1195
+ "questionEntities": question_entities,
1196
+ }
1115
1197
 
1116
- search_results: list[dict[str, Any]] = []
1198
+ search_jobs: list[tuple[dict[str, str], str, str, str]] = []
1117
1199
  for candidate in search_candidates:
1118
1200
  entity_type = str(candidate.get("type") or "")
1119
1201
  name = str(candidate.get("value") or "")
1120
1202
  if not entity_type or not name:
1121
1203
  continue
1122
- trace_id = new_trace_id("trace_entity")
1123
- try:
1124
- response = self.mcp_client.search_entities(
1125
- trace_id=trace_id,
1126
- session_id=context.session_id,
1127
- entity_types=[entity_type],
1128
- name=name,
1129
- )
1130
- except AttributeError:
1131
- break
1132
- except Exception as exc:
1204
+ search_jobs.append((candidate, entity_type, name, new_trace_id("trace_entity")))
1205
+
1206
+ search_results: list[dict[str, Any]] = []
1207
+ if search_jobs:
1208
+ max_workers = min(4, len(search_jobs))
1209
+
1210
+ def _run_search(job: tuple[dict[str, str], str, str, str]) -> dict[str, Any] | None:
1211
+ candidate, entity_type, name, trace_id = job
1212
+ try:
1213
+ response = self.mcp_client.search_entities(
1214
+ trace_id=trace_id,
1215
+ session_id=context.session_id,
1216
+ entity_types=[entity_type],
1217
+ name=name,
1218
+ )
1219
+ except AttributeError:
1220
+ raise
1221
+ except Exception as exc:
1222
+ self.audit_logger.log(
1223
+ {
1224
+ "traceId": trace_id,
1225
+ "sessionId": context.session_id,
1226
+ "question": question,
1227
+ "tool": "search_entities",
1228
+ "entityType": entity_type,
1229
+ "name": name,
1230
+ "error": f"{type(exc).__name__}: {exc}",
1231
+ }
1232
+ )
1233
+ return None
1133
1234
  self.audit_logger.log(
1134
1235
  {
1135
1236
  "traceId": trace_id,
@@ -1138,28 +1239,37 @@ class SpongeAgent:
1138
1239
  "tool": "search_entities",
1139
1240
  "entityType": entity_type,
1140
1241
  "name": name,
1141
- "error": f"{type(exc).__name__}: {exc}",
1242
+ "matchedCount": len(_flatten_entity_search_items(response)),
1142
1243
  }
1143
1244
  )
1144
- continue
1145
- self.audit_logger.log(
1146
- {
1147
- "traceId": trace_id,
1148
- "sessionId": context.session_id,
1149
- "question": question,
1150
- "tool": "search_entities",
1151
- "entityType": entity_type,
1152
- "name": name,
1153
- "matchedCount": len(_flatten_entity_search_items(response)),
1154
- }
1155
- )
1156
- search_results.append(
1157
- {
1245
+ return {
1158
1246
  "candidate": candidate,
1159
1247
  "searchedName": name,
1160
1248
  "entityType": entity_type,
1161
1249
  "response": response,
1162
1250
  }
1251
+
1252
+ try:
1253
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
1254
+ futures = [executor.submit(_run_search, job) for job in search_jobs]
1255
+ for future in as_completed(futures):
1256
+ item = future.result()
1257
+ if item is not None:
1258
+ search_results.append(item)
1259
+ except AttributeError:
1260
+ # Older/fake clients without search_entities: keep prior fail-soft behavior.
1261
+ search_results = []
1262
+
1263
+ # Preserve candidate order for stable query-plan / audit readability.
1264
+ order = {
1265
+ (str(candidate.get("type") or ""), str(candidate.get("value") or "")): index
1266
+ for index, (candidate, _, _, _) in enumerate(search_jobs)
1267
+ }
1268
+ search_results.sort(
1269
+ key=lambda item: order.get(
1270
+ (str(item.get("entityType") or ""), str(item.get("searchedName") or "")),
1271
+ 10**9,
1272
+ )
1163
1273
  )
1164
1274
  return {
1165
1275
  "traceId": search_results[0]["response"].get("traceId") if search_results else "",
@@ -1168,6 +1278,7 @@ class SpongeAgent:
1168
1278
  "schemaCandidates": schema_candidates,
1169
1279
  "searchResults": search_results,
1170
1280
  "entities": _merge_entity_search_results(search_results),
1281
+ "questionEntities": question_entities,
1171
1282
  }
1172
1283
 
1173
1284
  def _validate_sql(
@@ -1272,40 +1383,75 @@ class SpongeAgent:
1272
1383
 
1273
1384
  class _ExecutionSteps:
1274
1385
  def __init__(self) -> None:
1386
+ self._query_started_at = time.perf_counter()
1387
+ self._step_started_at: dict[int, float] = {}
1275
1388
  self.steps: list[dict[str, Any]] = [
1276
1389
  {"step": 1, "name": "接收 query_sponge 调用", "status": "pending"},
1277
- {"step": 2, "name": "LLM 需求分析", "status": "pending"},
1278
- {"step": 3, "name": "获取 schema", "status": "pending"},
1279
- {"step": 4, "name": "按读取顺序匹配 schemaContext", "status": "pending"},
1280
- {"step": 5, "name": "识别并确认实体", "status": "pending"},
1281
- {"step": 6, "name": "生成并确认查询计划", "status": "pending"},
1282
- {"step": 7, "name": " policy 和 schemaContext 生成 SQL", "status": "pending"},
1283
- {"step": 8, "name": "生成后按 policy 复查 SQL", "status": "pending"},
1284
- {"step": 9, "name": "修复 SQL", "status": "pending"},
1285
- {"step": 10, "name": "调用 validate_sql", "status": "pending"},
1286
- {"step": 11, "name": "调用 execute_sql", "status": "pending"},
1287
- {"step": 12, "name": "LLM 渲染并返回结果", "status": "pending"},
1390
+ {"step": 2, "name": "确认 schema 与最新版本一致", "status": "pending"},
1391
+ {"step": 3, "name": "LLM 需求分析", "status": "pending"},
1392
+ {"step": 4, "name": "匹配 schemaContext 并检索实体", "status": "pending"},
1393
+ {"step": 5, "name": "按查询计划生成 SQL", "status": "pending"},
1394
+ {"step": 6, "name": "生成后按 policy 复查 SQL", "status": "pending"},
1395
+ {"step": 7, "name": "修复 SQL", "status": "pending"},
1396
+ {"step": 8, "name": "调用 validate_sql", "status": "pending"},
1397
+ {"step": 9, "name": "调用 execute_sql", "status": "pending"},
1398
+ {"step": 10, "name": "渲染并返回结果", "status": "pending"},
1288
1399
  ]
1289
1400
 
1290
1401
  def running(self, step: int, detail: str | None = None) -> None:
1402
+ self._step_started_at[step] = time.perf_counter()
1291
1403
  self._set(step, "running", detail=detail)
1292
1404
 
1293
1405
  def done(self, step: int, detail: str | None = None) -> None:
1294
- self._set(step, "done", detail=detail)
1406
+ self._finish(step, "done", detail=detail)
1295
1407
 
1296
1408
  def waiting(self, step: int, detail: str | None = None) -> None:
1297
- self._set(step, "waiting", detail=detail)
1409
+ self._finish(step, "waiting", detail=detail)
1298
1410
 
1299
1411
  def skipped(self, step: int, detail: str | None = None) -> None:
1300
- self._set(step, "skipped", detail=detail)
1412
+ self._finish(step, "skipped", detail=detail)
1301
1413
 
1302
1414
  def error(self, step: int, reason: str) -> None:
1303
- self._set(step, "error", detail=reason, error=reason)
1415
+ self._finish(step, "error", detail=reason, error=reason)
1304
1416
 
1305
1417
  def snapshot(self) -> list[dict[str, Any]]:
1306
1418
  return [dict(step) for step in self.steps]
1307
1419
 
1308
- def _set(self, step: int, status: str, *, detail: str | None = None, error: str | None = None) -> None:
1420
+ def timing_summary(self) -> dict[str, Any]:
1421
+ total_ms = int(round((time.perf_counter() - self._query_started_at) * 1000))
1422
+ step_ms = {
1423
+ str(item["step"]): int(item.get("durationMs") or 0)
1424
+ for item in self.steps
1425
+ if item.get("status") != "pending"
1426
+ }
1427
+ return {
1428
+ "totalDurationMs": total_ms,
1429
+ "stepDurationMs": step_ms,
1430
+ }
1431
+
1432
+ def _finish(
1433
+ self,
1434
+ step: int,
1435
+ status: str,
1436
+ *,
1437
+ detail: str | None = None,
1438
+ error: str | None = None,
1439
+ ) -> None:
1440
+ started = self._step_started_at.pop(step, None)
1441
+ elapsed_ms = 0
1442
+ if started is not None:
1443
+ elapsed_ms = int(round((time.perf_counter() - started) * 1000))
1444
+ self._set(step, status, detail=detail, error=error, add_duration_ms=elapsed_ms)
1445
+
1446
+ def _set(
1447
+ self,
1448
+ step: int,
1449
+ status: str,
1450
+ *,
1451
+ detail: str | None = None,
1452
+ error: str | None = None,
1453
+ add_duration_ms: int | None = None,
1454
+ ) -> None:
1309
1455
  for item in self.steps:
1310
1456
  if item["step"] != step:
1311
1457
  continue
@@ -1314,6 +1460,8 @@ class _ExecutionSteps:
1314
1460
  item["detail"] = detail
1315
1461
  if error:
1316
1462
  item["error"] = error
1463
+ if add_duration_ms is not None:
1464
+ item["durationMs"] = int(item.get("durationMs") or 0) + max(0, add_duration_ms)
1317
1465
  return
1318
1466
 
1319
1467
 
@@ -1330,6 +1478,9 @@ def _attach_execution_steps(result: QueryResult, steps: _ExecutionSteps) -> Quer
1330
1478
  clarification_payload=result.clarification_payload,
1331
1479
  query_plan=result.query_plan,
1332
1480
  execution_steps=snapshot,
1481
+ timing=steps.timing_summary(),
1482
+ raw_result=result.raw_result,
1483
+ result_format=result.result_format,
1333
1484
  )
1334
1485
 
1335
1486
 
@@ -1352,11 +1503,14 @@ def _schema_context_step_detail(schema_context: Any) -> str:
1352
1503
 
1353
1504
 
1354
1505
  def _entity_search_step_detail(searched_entities: dict[str, Any] | None) -> str:
1355
- if not searched_entities:
1356
- return "未发现需要远程确认的实体。"
1506
+ if not searched_entities or not _has_entity_resolution(searched_entities):
1507
+ return "复用需求分析实体候选;未发现需要远程检索的实体。"
1357
1508
  items = _flatten_entity_search_items(searched_entities)
1358
1509
  schema_count = len(searched_entities.get("schemaCandidates") or [])
1359
- return f"search_entities 返回 {len(items)} 个候选;schema 路径 {schema_count} 个实体。"
1510
+ return (
1511
+ "复用需求分析实体候选;"
1512
+ f"search_entities 返回 {len(items)} 个候选;schema 路径 {schema_count} 个实体。"
1513
+ )
1360
1514
 
1361
1515
 
1362
1516
  def _sql_generation_clarification_result(
@@ -1388,33 +1542,48 @@ def _validation_error_message(validation: dict[str, Any] | None) -> str:
1388
1542
  return code or message
1389
1543
 
1390
1544
 
1545
+ def _has_entity_resolution(searched_entities: dict[str, Any] | None) -> bool:
1546
+ """True when Step 5 already produced search/schema candidates for the plan."""
1547
+ if not isinstance(searched_entities, dict):
1548
+ return False
1549
+ return bool(
1550
+ searched_entities.get("entityCandidates")
1551
+ or searched_entities.get("schemaCandidates")
1552
+ or searched_entities.get("searchResults")
1553
+ )
1554
+
1555
+
1556
+ def _question_entities_from_searched_entities(
1557
+ searched_entities: dict[str, Any],
1558
+ ) -> list[dict[str, str]] | None:
1559
+ """Consume Step 5's flat entity list only; never re-extract in the query-plan step."""
1560
+ cached = searched_entities.get("questionEntities") or []
1561
+ question_entities = [item for item in cached if isinstance(item, dict)]
1562
+ if _has_entity_resolution(searched_entities):
1563
+ # Search/schema paths already feed filters/entities; avoid duplicate slots.
1564
+ return None
1565
+ return question_entities
1566
+
1567
+
1391
1568
  def _build_query_plan(
1392
1569
  question: str,
1393
1570
  schema_context: Any,
1394
1571
  schema: dict[str, Any],
1395
1572
  *,
1396
- searched_entities: dict[str, Any] | None = None,
1397
- sampler: Sampler | None = None,
1573
+ searched_entities: dict[str, Any],
1398
1574
  time_range_question: str | None = None,
1399
1575
  analysis: QuestionAnalysis | None = None,
1400
1576
  ) -> dict[str, Any]:
1401
1577
  tables = _query_plan_tables(getattr(schema_context, "tables", []) or [])
1402
1578
  fields = _query_plan_fields(question, getattr(schema_context, "tables", []) or [])
1403
- # Extract open-valued entities once (schema-driven LLM), reused by both
1404
- # filters and entities so the two views never diverge.
1405
- question_entities = _extract_question_entities(
1406
- question,
1407
- schema,
1408
- sampler,
1409
- agent_instructions=getattr(schema_context, "agent_instructions", []) or [],
1410
- analysis=analysis,
1411
- )
1579
+ # Step 5 already extracted entities into searched_entities.questionEntities.
1580
+ question_entities = _question_entities_from_searched_entities(searched_entities)
1412
1581
  selected_concepts = match_concepts_for_question(getattr(schema_context, "concepts", []) or [], question)
1413
1582
  sql_filters = _query_plan_sql_filters(
1414
1583
  question,
1415
1584
  schema_context,
1416
1585
  searched_entities=searched_entities,
1417
- question_entities=question_entities if searched_entities is None else None,
1586
+ question_entities=question_entities,
1418
1587
  schema=schema,
1419
1588
  time_range_question=time_range_question or question,
1420
1589
  selected_concepts=selected_concepts,
@@ -1429,7 +1598,7 @@ def _build_query_plan(
1429
1598
  question,
1430
1599
  schema_context,
1431
1600
  searched_entities=searched_entities,
1432
- question_entities=question_entities if searched_entities is None else None,
1601
+ question_entities=question_entities,
1433
1602
  analysis=analysis,
1434
1603
  )
1435
1604
  plan = {
@@ -1502,11 +1671,15 @@ def _ensure_query_plan_bridge_tables(plan: dict[str, Any]) -> None:
1502
1671
  names.discard("")
1503
1672
  job_tables = {"job", "job_basic_info", "job_address", "job_store", "job_hiring_requirement"}
1504
1673
  has_store = "store" in names
1505
- if not has_store:
1506
- for flt in _plan_sql_filters(plan):
1507
- if isinstance(flt, dict) and str(flt.get("field") or "").startswith("store."):
1508
- has_store = True
1509
- break
1674
+ needs_job_address = False
1675
+ for flt in _plan_sql_filters(plan):
1676
+ if not isinstance(flt, dict):
1677
+ continue
1678
+ field = str(flt.get("field") or "")
1679
+ if field.startswith("store."):
1680
+ has_store = True
1681
+ if field.startswith("job_address.") or field in {"city.name", "region.name"}:
1682
+ needs_job_address = True
1510
1683
  if not has_store:
1511
1684
  for entity in plan.get("entities") or []:
1512
1685
  if isinstance(entity, dict) and str(entity.get("type") or "") == "store":
@@ -1520,6 +1693,15 @@ def _ensure_query_plan_bridge_tables(plan: dict[str, Any]) -> None:
1520
1693
  "source": "schema.planRules",
1521
1694
  }
1522
1695
  )
1696
+ names.add("job_store")
1697
+ if needs_job_address and names.intersection(job_tables - {"job_address"}) and "job_address" not in names:
1698
+ tables.append(
1699
+ {
1700
+ "name": "job_address",
1701
+ "description": "岗位地址。 对应表:job_address。",
1702
+ "source": "schema.planRules",
1703
+ }
1704
+ )
1523
1705
 
1524
1706
 
1525
1707
  def _ensure_query_plan_entity_display_fields(plan: dict[str, Any]) -> None:
@@ -1851,38 +2033,124 @@ def _region_name_filter_values(name: str) -> list[str]:
1851
2033
  cleaned = name.strip()
1852
2034
  if not cleaned:
1853
2035
  return []
1854
- if cleaned.endswith("区"):
1855
- base = cleaned[:-1]
1856
- return list(dict.fromkeys([cleaned, base]))
1857
- if cleaned.endswith("县"):
2036
+ if cleaned.endswith(("区", "县", "旗", "市")):
1858
2037
  base = cleaned[:-1]
1859
2038
  return list(dict.fromkeys([cleaned, base]))
1860
- return [cleaned]
2039
+ return list(dict.fromkeys([cleaned, f"{cleaned}市"]))
1861
2040
 
1862
2041
 
1863
- def _promote_region_search_candidate(
2042
+ def _location_place_key(value: str) -> str:
2043
+ """Normalize a place name for comparing city/region tokens (苏州/苏州市 → 苏州)."""
2044
+ cleaned = value.strip()
2045
+ if not cleaned:
2046
+ return ""
2047
+ for suffix in ("自治州", "地区", "市", "区", "县", "旗"):
2048
+ if cleaned.endswith(suffix) and len(cleaned) > len(suffix):
2049
+ return cleaned[: -len(suffix)]
2050
+ return cleaned
2051
+
2052
+
2053
+ def _distinct_location_place_keys(search_candidates: list[dict[str, str]]) -> set[str]:
2054
+ keys: set[str] = set()
2055
+ for candidate in search_candidates:
2056
+ if str(candidate.get("type") or "") not in {"city", "region"}:
2057
+ continue
2058
+ key = _location_place_key(str(candidate.get("value") or ""))
2059
+ if key:
2060
+ keys.add(key)
2061
+ return keys
2062
+
2063
+
2064
+ def _is_hierarchical_location_query(search_candidates: list[dict[str, str]]) -> bool:
2065
+ """True when the question mentions two+ distinct places (e.g. 苏州 + 昆山市)."""
2066
+ return len(_distinct_location_place_keys(search_candidates)) >= 2
2067
+
2068
+
2069
+ def _entity_hit_matches_searched_name(hit_name: str, searched_name: str) -> bool:
2070
+ """Keep structured hits that refer to the same place token; drop loose contains matches."""
2071
+ hit = hit_name.strip()
2072
+ searched = searched_name.strip()
2073
+ if not hit or not searched:
2074
+ return bool(hit)
2075
+ hit_key = _location_place_key(hit)
2076
+ search_key = _location_place_key(searched)
2077
+ if not search_key:
2078
+ return True
2079
+ if hit_key == search_key:
2080
+ return True
2081
+ if hit in {searched, search_key, f"{search_key}市", f"{search_key}区", f"{search_key}县"}:
2082
+ return True
2083
+ if searched in {hit, hit_key}:
2084
+ return True
2085
+ return False
2086
+
2087
+
2088
+ def _location_address_filter_values(name: str) -> list[str]:
2089
+ cleaned = name.strip()
2090
+ if not cleaned:
2091
+ return []
2092
+ variants = list(
2093
+ dict.fromkeys(_city_name_filter_values(cleaned) + _region_name_filter_values(cleaned))
2094
+ )
2095
+ return variants or [cleaned]
2096
+
2097
+
2098
+ def _ensure_location_search_candidate(
1864
2099
  search_candidates: list[dict[str, str]],
1865
- schema_candidates: list[dict[str, str]],
1866
2100
  *,
2101
+ entity_type: str,
2102
+ label: str,
1867
2103
  value: str,
1868
2104
  schema: dict[str, Any],
1869
2105
  seen_search: set[tuple[str, str]],
1870
2106
  ) -> None:
1871
- field = _entity_table_name_column("region", schema) or "region.name"
1872
- key = ("region", value)
2107
+ key = (entity_type, value)
1873
2108
  if key in seen_search:
1874
2109
  return
1875
2110
  seen_search.add(key)
2111
+ maps_to = entity_type
2112
+ field = _entity_table_name_column(maps_to, schema) or f"{maps_to}.name"
1876
2113
  search_candidates.append(
1877
2114
  {
1878
- "type": "region",
1879
- "label": "区域",
2115
+ "type": entity_type,
2116
+ "label": label,
1880
2117
  "value": value,
1881
2118
  "field": field,
1882
- "mapsTo": "region",
2119
+ "mapsTo": maps_to,
1883
2120
  "source": "search_entities.candidate",
1884
2121
  }
1885
2122
  )
2123
+
2124
+
2125
+ def _drop_location_schema_store_candidates(
2126
+ schema_candidates: list[dict[str, str]],
2127
+ *,
2128
+ value: str,
2129
+ ) -> None:
2130
+ schema_candidates[:] = [
2131
+ item
2132
+ for item in schema_candidates
2133
+ if not (str(item.get("type") or "") == "store" and str(item.get("value") or "") == value)
2134
+ ]
2135
+
2136
+
2137
+ def _promote_region_search_candidate(
2138
+ search_candidates: list[dict[str, str]],
2139
+ schema_candidates: list[dict[str, str]],
2140
+ *,
2141
+ value: str,
2142
+ schema: dict[str, Any],
2143
+ seen_search: set[tuple[str, str]],
2144
+ ) -> None:
2145
+ """District-like names (区/县/旗) resolve to region only."""
2146
+ _ensure_location_search_candidate(
2147
+ search_candidates,
2148
+ entity_type="region",
2149
+ label="区域",
2150
+ value=value,
2151
+ schema=schema,
2152
+ seen_search=seen_search,
2153
+ )
1886
2154
  schema_candidates[:] = [
1887
2155
  item
1888
2156
  for item in schema_candidates
@@ -1901,7 +2169,7 @@ def _promote_region_search_candidate(
1901
2169
  ]
1902
2170
 
1903
2171
 
1904
- def _promote_city_search_candidate(
2172
+ def _ensure_location_dual_search_candidates(
1905
2173
  search_candidates: list[dict[str, str]],
1906
2174
  schema_candidates: list[dict[str, str]],
1907
2175
  *,
@@ -1909,28 +2177,48 @@ def _promote_city_search_candidate(
1909
2177
  schema: dict[str, Any],
1910
2178
  seen_search: set[tuple[str, str]],
1911
2179
  ) -> None:
1912
- field = _entity_table_name_column("city", schema) or "city.name"
1913
- key = ("city", value)
1914
- if key in seen_search:
1915
- return
1916
- seen_search.add(key)
1917
- search_candidates.append(
1918
- {
1919
- "type": "city",
1920
- "label": "城市",
1921
- "value": value,
1922
- "field": field,
1923
- "mapsTo": "city",
1924
- "source": "search_entities.candidate",
1925
- }
2180
+ """City-like place names may live in city or region (e.g. county-level 江阴市)."""
2181
+ _ensure_location_search_candidate(
2182
+ search_candidates,
2183
+ entity_type="city",
2184
+ label="城市",
2185
+ value=value,
2186
+ schema=schema,
2187
+ seen_search=seen_search,
1926
2188
  )
1927
- schema_candidates[:] = [
2189
+ _ensure_location_search_candidate(
2190
+ search_candidates,
2191
+ entity_type="region",
2192
+ label="区域",
2193
+ value=value,
2194
+ schema=schema,
2195
+ seen_search=seen_search,
2196
+ )
2197
+ _drop_location_schema_store_candidates(schema_candidates, value=value)
2198
+ search_candidates[:] = [
1928
2199
  item
1929
- for item in schema_candidates
2200
+ for item in search_candidates
1930
2201
  if not (str(item.get("type") or "") == "store" and str(item.get("value") or "") == value)
1931
2202
  ]
1932
2203
 
1933
2204
 
2205
+ def _promote_city_search_candidate(
2206
+ search_candidates: list[dict[str, str]],
2207
+ schema_candidates: list[dict[str, str]],
2208
+ *,
2209
+ value: str,
2210
+ schema: dict[str, Any],
2211
+ seen_search: set[tuple[str, str]],
2212
+ ) -> None:
2213
+ _ensure_location_dual_search_candidates(
2214
+ search_candidates,
2215
+ schema_candidates,
2216
+ value=value,
2217
+ schema=schema,
2218
+ seen_search=seen_search,
2219
+ )
2220
+
2221
+
1934
2222
  def _extract_region_tokens(question: str) -> list[str]:
1935
2223
  tokens: list[str] = []
1936
2224
  for match in re.finditer(r"([\u4e00-\u9fff]{2,4})区", question):
@@ -1989,14 +2277,147 @@ def _explicit_brand_or_project_types(llm_values: dict[str, Any]) -> set[str]:
1989
2277
  return {str(item) for item in explicit_types if str(item) in {"brand", "sponge_project"}}
1990
2278
 
1991
2279
 
2280
+ def _extract_hierarchical_location_pair(question: str) -> tuple[str, str] | None:
2281
+ """Detect parent city + child region phrases like 苏州市昆山市 / 无锡市江阴市."""
2282
+ match = re.search(
2283
+ r"([\u4e00-\u9fff]{2,3})市([\u4e00-\u9fff]{2,4}(?:市|区|县|旗))",
2284
+ question,
2285
+ )
2286
+ if not match:
2287
+ return None
2288
+ parent, child = match.group(1), match.group(2)
2289
+ if _location_place_key(parent) == _location_place_key(child):
2290
+ return None
2291
+ return parent, child
2292
+
2293
+
2294
+ def _seed_hierarchical_location_candidates(
2295
+ question: str,
2296
+ search_candidates: list[dict[str, str]],
2297
+ schema_candidates: list[dict[str, str]],
2298
+ schema: dict[str, Any],
2299
+ seen_search: set[tuple[str, str]],
2300
+ ) -> None:
2301
+ """Ensure parent city + child region are both searchable for hierarchical questions."""
2302
+ pair = _extract_hierarchical_location_pair(question)
2303
+ if pair is None and _is_hierarchical_location_query(search_candidates):
2304
+ # LLM already filled distinct city + region slots (苏州 + 昆山市).
2305
+ return
2306
+ if pair is None:
2307
+ # Soft pattern without 市 on parent: only when city+region slots already differ.
2308
+ city_values = [
2309
+ str(item.get("value") or "")
2310
+ for item in search_candidates
2311
+ if str(item.get("type") or "") == "city" and str(item.get("value") or "").strip()
2312
+ ]
2313
+ region_values = [
2314
+ str(item.get("value") or "")
2315
+ for item in search_candidates
2316
+ if str(item.get("type") or "") == "region" and str(item.get("value") or "").strip()
2317
+ ]
2318
+ if len(city_values) == 1 and len(region_values) == 1:
2319
+ parent, child = city_values[0], region_values[0]
2320
+ if _location_place_key(parent) != _location_place_key(child):
2321
+ pair = (_location_place_key(parent) or parent, child)
2322
+ if pair is None:
2323
+ soft = re.search(
2324
+ r"([\u4e00-\u9fff]{2})([\u4e00-\u9fff]{2,4}(?:市|区|县|旗))",
2325
+ question,
2326
+ )
2327
+ if soft and city_values and region_values:
2328
+ parent, child = soft.group(1), soft.group(2)
2329
+ if (
2330
+ _looks_like_city_name(parent)
2331
+ and _location_place_key(parent) != _location_place_key(child)
2332
+ and any(_location_place_key(parent) == _location_place_key(v) for v in city_values)
2333
+ and any(_location_place_key(child) == _location_place_key(v) for v in region_values)
2334
+ ):
2335
+ pair = (parent, child)
2336
+ if pair is None:
2337
+ return
2338
+ parent, child = pair
2339
+ parent_key = _location_place_key(parent)
2340
+ child_key = _location_place_key(child)
2341
+ for item in list(search_candidates) + list(schema_candidates):
2342
+ item_type = str(item.get("type") or "")
2343
+ if item_type not in {"brand", "project", "sponge_project"}:
2344
+ continue
2345
+ name = str(item.get("value") or "")
2346
+ if parent_key and parent_key in name:
2347
+ return
2348
+ # Drop fused tokens like 苏州昆山市 that should be split into city+region.
2349
+ search_candidates[:] = [
2350
+ item
2351
+ for item in search_candidates
2352
+ if not (
2353
+ str(item.get("type") or "") in {"city", "region"}
2354
+ and parent_key
2355
+ and child_key
2356
+ and parent_key in str(item.get("value") or "")
2357
+ and child_key in str(item.get("value") or "")
2358
+ and _location_place_key(str(item.get("value") or "")) not in {parent_key, child_key}
2359
+ )
2360
+ ]
2361
+ seen_search.clear()
2362
+ seen_search.update(
2363
+ (str(item.get("type") or ""), str(item.get("value") or "")) for item in search_candidates
2364
+ )
2365
+ _ensure_location_search_candidate(
2366
+ search_candidates,
2367
+ entity_type="city",
2368
+ label="城市",
2369
+ value=parent,
2370
+ schema=schema,
2371
+ seen_search=seen_search,
2372
+ )
2373
+ if _looks_like_region_name(child) or child.endswith("市"):
2374
+ _ensure_location_search_candidate(
2375
+ search_candidates,
2376
+ entity_type="region",
2377
+ label="区域",
2378
+ value=child,
2379
+ schema=schema,
2380
+ seen_search=seen_search,
2381
+ )
2382
+ search_candidates[:] = [
2383
+ item
2384
+ for item in search_candidates
2385
+ if not (
2386
+ str(item.get("type") or "") == "city"
2387
+ and _location_place_key(str(item.get("value") or "")) == child_key
2388
+ )
2389
+ ]
2390
+ seen_search.clear()
2391
+ seen_search.update(
2392
+ (str(item.get("type") or ""), str(item.get("value") or "")) for item in search_candidates
2393
+ )
2394
+ _drop_location_schema_store_candidates(schema_candidates, value=parent)
2395
+ _drop_location_schema_store_candidates(schema_candidates, value=child)
2396
+
2397
+
1992
2398
  def _reconcile_location_entity_candidates(
1993
2399
  question: str,
1994
2400
  search_candidates: list[dict[str, str]],
1995
2401
  schema_candidates: list[dict[str, str]],
1996
2402
  schema: dict[str, Any],
1997
2403
  ) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
1998
- """Move location tokens onto city/region search paths; keep store for store-like names."""
2404
+ """Route location tokens to city/region search; keep store for store-like names.
2405
+
2406
+ District-like names (区/县/旗) go to region only. A single city-like name
2407
+ (含「市」及短地名) dual-searches city and region so county-level cities like
2408
+ 江阴市 can hit region. When the question already has two+ distinct places
2409
+ (苏州 + 昆山市), keep city/region slots as hierarchical AND filters and do
2410
+ not dual-search each token.
2411
+ """
1999
2412
  seen_search = {(str(item.get("type") or ""), str(item.get("value") or "")) for item in search_candidates}
2413
+ _seed_hierarchical_location_candidates(
2414
+ question,
2415
+ search_candidates,
2416
+ schema_candidates,
2417
+ schema,
2418
+ seen_search,
2419
+ )
2420
+ hierarchical = _is_hierarchical_location_query(search_candidates)
2000
2421
  for token in _extract_region_tokens(question):
2001
2422
  if _looks_like_region_name(token):
2002
2423
  _promote_region_search_candidate(
@@ -2022,20 +2443,55 @@ def _reconcile_location_entity_candidates(
2022
2443
  if str(candidate.get("type") or "") != "store":
2023
2444
  continue
2024
2445
  if _looks_like_city_name(value):
2025
- _promote_city_search_candidate(
2446
+ if hierarchical:
2447
+ _ensure_location_search_candidate(
2448
+ search_candidates,
2449
+ entity_type="city",
2450
+ label="城市",
2451
+ value=value,
2452
+ schema=schema,
2453
+ seen_search=seen_search,
2454
+ )
2455
+ _drop_location_schema_store_candidates(schema_candidates, value=value)
2456
+ else:
2457
+ _ensure_location_dual_search_candidates(
2458
+ search_candidates,
2459
+ schema_candidates,
2460
+ value=value,
2461
+ schema=schema,
2462
+ seen_search=seen_search,
2463
+ )
2464
+ for candidate in list(search_candidates):
2465
+ value = str(candidate.get("value") or "").strip()
2466
+ if not value:
2467
+ continue
2468
+ candidate_type = str(candidate.get("type") or "")
2469
+ if candidate_type == "city" and _looks_like_region_name(value):
2470
+ search_candidates.remove(candidate)
2471
+ seen_search.discard(("city", value))
2472
+ _promote_region_search_candidate(
2026
2473
  search_candidates,
2027
2474
  schema_candidates,
2028
2475
  value=value,
2029
2476
  schema=schema,
2030
2477
  seen_search=seen_search,
2031
2478
  )
2032
- for candidate in list(search_candidates):
2033
- value = str(candidate.get("value") or "").strip()
2034
- if not value:
2035
2479
  continue
2036
- if str(candidate.get("type") or "") == "city" and _looks_like_region_name(value):
2037
- search_candidates.remove(candidate)
2038
- _promote_region_search_candidate(
2480
+ if hierarchical:
2481
+ if candidate_type == "store" and _looks_like_city_name(value):
2482
+ search_candidates.remove(candidate)
2483
+ seen_search.discard(("store", value))
2484
+ _ensure_location_search_candidate(
2485
+ search_candidates,
2486
+ entity_type="city",
2487
+ label="城市",
2488
+ value=value,
2489
+ schema=schema,
2490
+ seen_search=seen_search,
2491
+ )
2492
+ continue
2493
+ if candidate_type in {"city", "region"} and _looks_like_city_name(value):
2494
+ _ensure_location_dual_search_candidates(
2039
2495
  search_candidates,
2040
2496
  schema_candidates,
2041
2497
  value=value,
@@ -2043,11 +2499,12 @@ def _reconcile_location_entity_candidates(
2043
2499
  seen_search=seen_search,
2044
2500
  )
2045
2501
  continue
2046
- if str(candidate.get("type") or "") != "store":
2502
+ if candidate_type != "store":
2047
2503
  continue
2048
2504
  if _looks_like_city_name(value):
2049
2505
  search_candidates.remove(candidate)
2050
- _promote_city_search_candidate(
2506
+ seen_search.discard(("store", value))
2507
+ _ensure_location_dual_search_candidates(
2051
2508
  search_candidates,
2052
2509
  schema_candidates,
2053
2510
  value=value,
@@ -2065,8 +2522,13 @@ def _collect_entity_resolution_candidates(
2065
2522
  *,
2066
2523
  agent_instructions: list[dict[str, Any]] | None = None,
2067
2524
  analysis: QuestionAnalysis | None = None,
2068
- ) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
2069
- """Split entity mentions into search_entities candidates and schema-path candidates."""
2525
+ ) -> tuple[list[dict[str, str]], list[dict[str, str]], list[dict[str, str]]]:
2526
+ """Split entity mentions into search/schema candidates and a query-plan flat list.
2527
+
2528
+ Returns ``(search_candidates, schema_candidates, question_entities)``. The flat
2529
+ ``question_entities`` list is produced from the same extraction pass so SQL
2530
+ generation can reuse it without a second LLM call.
2531
+ """
2070
2532
  search_candidates: list[dict[str, str]] = []
2071
2533
  schema_candidates: list[dict[str, str]] = []
2072
2534
  seen_search: set[tuple[str, str]] = set()
@@ -2074,10 +2536,17 @@ def _collect_entity_resolution_candidates(
2074
2536
 
2075
2537
  catalog = _entity_catalog(schema)
2076
2538
  llm_values: dict[str, Any] = {}
2077
- if sampler is not None and catalog:
2539
+ # Prefer Step-3 analysis.entityCandidates so the happy path needs only one
2540
+ # Sampling call (analysis) before SQL generation. Fall back to a dedicated
2541
+ # entity-slot Sampling only when analysis did not yield usable entity values.
2542
+ if analysis is not None:
2543
+ llm_values = _merge_analysis_entity_candidates_into_llm_values({}, analysis, catalog)
2544
+ _infer_explicit_entity_types_from_analysis(llm_values, analysis)
2545
+ if not _llm_values_have_entity_slots(llm_values, catalog) and sampler is not None and catalog:
2078
2546
  llm_values = _llm_extract_entity_values(question, catalog, sampler, agent_instructions)
2079
- llm_values = _merge_analysis_entity_candidates_into_llm_values(llm_values, analysis, catalog)
2547
+ llm_values = _merge_analysis_entity_candidates_into_llm_values(llm_values, analysis, catalog)
2080
2548
  _drop_implicit_location_values_inside_named_entities(llm_values)
2549
+ question_entities = _question_entities_from_llm_values(catalog, llm_values, question, analysis)
2081
2550
  for item in catalog:
2082
2551
  value = llm_values.get(item["type"]) or _extract_value_near_label(question, item["label"])
2083
2552
  if not value:
@@ -2134,10 +2603,69 @@ def _collect_entity_resolution_candidates(
2134
2603
  schema_candidates,
2135
2604
  schema,
2136
2605
  )
2137
- return search_candidates, schema_candidates
2606
+ return search_candidates, schema_candidates, question_entities
2607
+
2608
+
2609
+ def _merge_schema_context_entity_candidates(
2610
+ searched_entities: dict[str, Any] | None,
2611
+ question: str,
2612
+ schema_context: Any,
2613
+ *,
2614
+ analysis: QuestionAnalysis | None = None,
2615
+ ) -> dict[str, Any] | None:
2616
+ """Append schemaContext-only entity candidates after parallel step 4 completes."""
2617
+ if searched_entities is None:
2618
+ return None
2619
+ if schema_context is None:
2620
+ return searched_entities
2621
+ schema_candidates = list(searched_entities.get("schemaCandidates") or [])
2622
+ seen_schema = {
2623
+ (str(item.get("field") or ""), str(item.get("value") or ""))
2624
+ for item in schema_candidates
2625
+ if isinstance(item, dict)
2626
+ }
2627
+ added = False
2628
+ for candidate in _schema_generic_entity_candidates(question, schema_context):
2629
+ field = str(candidate.get("field") or "")
2630
+ value = str(candidate.get("value") or "")
2631
+ key = (field, value)
2632
+ if not value or key in seen_schema or _is_analysis_noise_value(value, analysis):
2633
+ continue
2634
+ seen_schema.add(key)
2635
+ schema_candidates.append(candidate)
2636
+ added = True
2637
+ if not added:
2638
+ return searched_entities
2639
+ merged = dict(searched_entities)
2640
+ merged["schemaCandidates"] = schema_candidates
2641
+ return merged
2642
+
2643
+
2644
+ def _question_entities_from_llm_values(
2645
+ catalog: list[dict[str, str]],
2646
+ llm_values: dict[str, Any],
2647
+ question: str,
2648
+ analysis: QuestionAnalysis | None,
2649
+ ) -> list[dict[str, str]]:
2650
+ """Build the query-plan flat entity list from one extraction pass."""
2651
+ results: list[dict[str, str]] = []
2652
+ for item in catalog:
2653
+ value = llm_values.get(item["type"]) or _extract_value_near_label(question, item["label"])
2654
+ if value and not _is_analysis_noise_value(value, analysis):
2655
+ results.append(
2656
+ {
2657
+ "type": item["type"],
2658
+ "label": item["label"],
2659
+ "filterField": item["filterField"],
2660
+ "value": value,
2661
+ }
2662
+ )
2663
+ return results
2138
2664
 
2139
2665
 
2140
2666
  def _schema_generic_entity_candidates(question: str, schema_context: Any) -> list[dict[str, str]]:
2667
+ if schema_context is None:
2668
+ return []
2141
2669
  candidates: list[dict[str, str]] = []
2142
2670
  for table in getattr(schema_context, "tables", []) or []:
2143
2671
  if not isinstance(table, dict):
@@ -2389,10 +2917,12 @@ def _entity_search_filter_items(search_response: dict[str, Any] | None) -> list[
2389
2917
  response = search_result.get("response")
2390
2918
  if not isinstance(response, dict):
2391
2919
  continue
2392
- group_key = _entity_search_alias_group_key(search_result)
2920
+ group_key = _entity_search_or_group_key(search_result)
2393
2921
  if not group_key:
2394
2922
  continue
2395
- group_counts[group_key] = group_counts.get(group_key, 0) + len(_flatten_entity_search_items(response))
2923
+ group_counts[group_key] = group_counts.get(group_key, 0) + len(
2924
+ _matched_entity_search_items(search_result)
2925
+ )
2396
2926
  for index, search_result in enumerate(search_results, start=1):
2397
2927
  if not isinstance(search_result, dict):
2398
2928
  continue
@@ -2402,17 +2932,20 @@ def _entity_search_filter_items(search_response: dict[str, Any] | None) -> list[
2402
2932
  searched_name = str(search_result.get("searchedName") or "")
2403
2933
  candidate = search_result.get("candidate")
2404
2934
  candidate_type = str(candidate.get("type") or "") if isinstance(candidate, dict) else ""
2405
- group_key = _entity_search_alias_group_key(search_result)
2935
+ group_key = _entity_search_or_group_key(search_result)
2406
2936
  if group_key and group_counts.get(group_key, 0) > 1:
2407
2937
  group_seed = f"entity_search:{group_key}"
2938
+ force_group = True
2408
2939
  else:
2409
2940
  group_seed = f"entity_search:{index}:{candidate_type}:{searched_name}"
2941
+ force_group = False
2410
2942
  result.extend(
2411
2943
  _entity_search_filter_items_for_response(
2412
2944
  response,
2413
2945
  searched_name=searched_name,
2414
2946
  group_seed=group_seed,
2415
- force_logical_group=bool(group_key and group_counts.get(group_key, 0) > 1),
2947
+ force_logical_group=force_group,
2948
+ candidate_type=candidate_type,
2416
2949
  )
2417
2950
  )
2418
2951
  return result
@@ -2428,14 +2961,60 @@ def _entity_search_alias_group_key(search_result: dict[str, Any]) -> str:
2428
2961
  return re.sub(r"[^0-9A-Za-z\u4e00-\u9fff]+", "", raw).lower()
2429
2962
 
2430
2963
 
2964
+ def _entity_search_or_group_key(search_result: dict[str, Any]) -> str:
2965
+ """OR-group key for ambiguous aliases of the *same* place/name.
2966
+
2967
+ City/region use the normalized place key so 苏州/苏州市 stay one OR group,
2968
+ while 苏州 + 昆山 stay separate (AND across hierarchy). Brand/project keep
2969
+ the raw alias key so 必胜客 / 南京必胜客 can still OR together.
2970
+ """
2971
+ candidate = search_result.get("candidate")
2972
+ candidate_type = str(candidate.get("type") or "") if isinstance(candidate, dict) else ""
2973
+ if candidate_type in {"city", "region"}:
2974
+ raw = ""
2975
+ if isinstance(candidate, dict):
2976
+ raw = str(candidate.get("value") or "")
2977
+ if not raw:
2978
+ raw = str(search_result.get("searchedName") or "")
2979
+ place_key = _location_place_key(raw)
2980
+ return place_key.lower() if place_key else _entity_search_alias_group_key(search_result)
2981
+ return _entity_search_alias_group_key(search_result)
2982
+
2983
+
2984
+ def _matched_entity_search_items(search_result: dict[str, Any]) -> list[dict[str, str]]:
2985
+ response = search_result.get("response")
2986
+ if not isinstance(response, dict):
2987
+ return []
2988
+ searched_name = str(search_result.get("searchedName") or "")
2989
+ candidate = search_result.get("candidate")
2990
+ candidate_type = str(candidate.get("type") or "") if isinstance(candidate, dict) else ""
2991
+ if not searched_name and isinstance(candidate, dict):
2992
+ searched_name = str(candidate.get("value") or "")
2993
+ items = _flatten_entity_search_items(response)
2994
+ if candidate_type not in {"city", "region"}:
2995
+ return items
2996
+ return [
2997
+ item
2998
+ for item in items
2999
+ if _entity_hit_matches_searched_name(str(item.get("name") or ""), searched_name)
3000
+ ]
3001
+
3002
+
2431
3003
  def _entity_search_filter_items_for_response(
2432
3004
  response: dict[str, Any],
2433
3005
  *,
2434
3006
  searched_name: str,
2435
3007
  group_seed: str,
2436
3008
  force_logical_group: bool = False,
3009
+ candidate_type: str = "",
2437
3010
  ) -> list[dict[str, str]]:
2438
3011
  items = _flatten_entity_search_items(response)
3012
+ if candidate_type in {"city", "region"} and searched_name:
3013
+ items = [
3014
+ item
3015
+ for item in items
3016
+ if _entity_hit_matches_searched_name(str(item.get("name") or ""), searched_name)
3017
+ ]
2439
3018
  if not items:
2440
3019
  return []
2441
3020
  logical_group = group_seed if force_logical_group or len(items) > 1 else ""
@@ -2451,7 +3030,10 @@ def _entity_search_filter_items_for_response(
2451
3030
  return result
2452
3031
 
2453
3032
 
2454
- def _direct_entity_candidates(search_response: dict[str, Any] | None) -> list[dict[str, str]]:
3033
+ def _direct_entity_candidates(
3034
+ search_response: dict[str, Any] | None,
3035
+ schema: dict[str, Any] | None = None,
3036
+ ) -> list[dict[str, str]]:
2455
3037
  if not isinstance(search_response, dict):
2456
3038
  return []
2457
3039
  candidates = [
@@ -2463,19 +3045,140 @@ def _direct_entity_candidates(search_response: dict[str, Any] | None) -> list[di
2463
3045
  if not isinstance(search_results, list):
2464
3046
  return candidates
2465
3047
  resolved: set[tuple[str, str]] = set()
3048
+ location_group_types: dict[str, set[str]] = {}
3049
+ location_group_hits: dict[str, bool] = {}
2466
3050
  for search_result in search_results:
2467
3051
  if not isinstance(search_result, dict):
2468
3052
  continue
2469
3053
  candidate = search_result.get("candidate")
2470
3054
  if not isinstance(candidate, dict):
2471
3055
  continue
2472
- if _flatten_entity_search_items(search_result.get("response")):
2473
- resolved.add((str(candidate.get("type") or ""), str(candidate.get("value") or "")))
2474
- return [
2475
- candidate
2476
- for candidate in candidates
2477
- if (str(candidate.get("type") or ""), str(candidate.get("value") or "")) not in resolved
2478
- ]
3056
+ candidate_type = str(candidate.get("type") or "")
3057
+ candidate_value = str(candidate.get("value") or "")
3058
+ group_key = _entity_search_or_group_key(search_result)
3059
+ if group_key and candidate_type in {"city", "region"}:
3060
+ location_group_types.setdefault(group_key, set()).add(candidate_type)
3061
+ if _matched_entity_search_items(search_result):
3062
+ resolved.add((candidate_type, candidate_value))
3063
+ if group_key and candidate_type in {"city", "region"}:
3064
+ location_group_hits[group_key] = True
3065
+ has_address_fallback = bool(schema and _schema_has_job_address_name(schema))
3066
+ # Dual city+region: never AND an empty side with a hit. Both-miss uses address
3067
+ # fallback when available; otherwise keep both sides as OR via logicalGroup.
3068
+ suppressed_unresolved_locations: set[tuple[str, str]] = set()
3069
+ or_group_unresolved_locations: set[tuple[str, str]] = set()
3070
+ for search_result in search_results:
3071
+ if not isinstance(search_result, dict):
3072
+ continue
3073
+ candidate = search_result.get("candidate")
3074
+ if not isinstance(candidate, dict):
3075
+ continue
3076
+ candidate_type = str(candidate.get("type") or "")
3077
+ candidate_value = str(candidate.get("value") or "")
3078
+ if candidate_type not in {"city", "region"}:
3079
+ continue
3080
+ if (candidate_type, candidate_value) in resolved:
3081
+ continue
3082
+ group_key = _entity_search_or_group_key(search_result)
3083
+ types = location_group_types.get(group_key) or set()
3084
+ if "city" not in types or "region" not in types:
3085
+ continue
3086
+ if location_group_hits.get(group_key):
3087
+ suppressed_unresolved_locations.add((candidate_type, candidate_value))
3088
+ elif has_address_fallback:
3089
+ suppressed_unresolved_locations.add((candidate_type, candidate_value))
3090
+ else:
3091
+ or_group_unresolved_locations.add((candidate_type, candidate_value))
3092
+ result: list[dict[str, str]] = []
3093
+ for candidate in candidates:
3094
+ key = (str(candidate.get("type") or ""), str(candidate.get("value") or ""))
3095
+ if key in resolved or key in suppressed_unresolved_locations:
3096
+ continue
3097
+ item = dict(candidate)
3098
+ if key in or_group_unresolved_locations:
3099
+ group_key = _location_place_key(key[1]).lower() or re.sub(
3100
+ r"[^0-9A-Za-z\u4e00-\u9fff]+", "", key[1]
3101
+ ).lower()
3102
+ item["logicalGroup"] = f"entity_search:{group_key}"
3103
+ item["logicalOperator"] = "OR"
3104
+ result.append(item)
3105
+ return result
3106
+
3107
+
3108
+ def _location_address_fallback_filters(
3109
+ searched_entities: dict[str, Any] | None,
3110
+ schema: dict[str, Any] | None,
3111
+ ) -> list[dict[str, str]]:
3112
+ """When city/region dual search both miss, fall back to job_address.name LIKE."""
3113
+ if not isinstance(searched_entities, dict) or not isinstance(schema, dict):
3114
+ return []
3115
+ if not _schema_has_job_address_name(schema):
3116
+ return []
3117
+ search_results = searched_entities.get("searchResults")
3118
+ if not isinstance(search_results, list) or not search_results:
3119
+ return []
3120
+ groups: dict[str, dict[str, Any]] = {}
3121
+ for search_result in search_results:
3122
+ if not isinstance(search_result, dict):
3123
+ continue
3124
+ candidate = search_result.get("candidate")
3125
+ if not isinstance(candidate, dict):
3126
+ continue
3127
+ candidate_type = str(candidate.get("type") or "")
3128
+ if candidate_type not in {"city", "region"}:
3129
+ continue
3130
+ group_key = _entity_search_or_group_key(search_result)
3131
+ if not group_key:
3132
+ continue
3133
+ bucket = groups.setdefault(
3134
+ group_key,
3135
+ {"types": set(), "hit": False, "value": str(candidate.get("value") or "").strip()},
3136
+ )
3137
+ bucket["types"].add(candidate_type)
3138
+ if not bucket["value"]:
3139
+ bucket["value"] = str(search_result.get("searchedName") or "").strip()
3140
+ if _matched_entity_search_items(search_result):
3141
+ bucket["hit"] = True
3142
+ filters: list[dict[str, str]] = []
3143
+ for group_key, bucket in groups.items():
3144
+ types = bucket["types"]
3145
+ if "city" not in types or "region" not in types:
3146
+ continue
3147
+ if bucket["hit"]:
3148
+ continue
3149
+ value = str(bucket["value"] or "").strip()
3150
+ if not value:
3151
+ continue
3152
+ variants = _location_address_filter_values(value)
3153
+ logical_group = f"entity_search:{group_key}:address_fallback"
3154
+ for variant in variants:
3155
+ filters.append(
3156
+ {
3157
+ "field": "job_address.name",
3158
+ "operator": "LIKE",
3159
+ "value": f"%{variant}%",
3160
+ "source": "search_entities.address_fallback",
3161
+ "logicalGroup": logical_group,
3162
+ "logicalOperator": "OR",
3163
+ }
3164
+ )
3165
+ return filters
3166
+
3167
+
3168
+ def _schema_has_job_address_name(schema: dict[str, Any]) -> bool:
3169
+ for table in schema.get("tables") or []:
3170
+ if not isinstance(table, dict):
3171
+ continue
3172
+ name = str(table.get("tableName") or table.get("name") or "")
3173
+ if name != "job_address":
3174
+ continue
3175
+ for column in table.get("columns") or []:
3176
+ if not isinstance(column, dict):
3177
+ continue
3178
+ column_name = str(column.get("columnName") or column.get("name") or "")
3179
+ if column_name == "name":
3180
+ return True
3181
+ return False
2479
3182
 
2480
3183
 
2481
3184
  def _entity_type_label(entity_type: str) -> str:
@@ -2550,7 +3253,9 @@ def _llm_extract_entity_values(
2550
3253
  "从用户问题中抽取字段值,"
2551
3254
  "只输出一个 JSON 对象,键为字段代码,值为问题中出现的实体名称原文;"
2552
3255
  "问题中没有提到的字段一律输出 null。不要输出任何解释或多余文本。"
2553
- "城市(如上海、北京)填 city;区县(如杨浦区、浦东新区)填 region,不要填 city;"
3256
+ "地级市(如上海、北京、无锡、苏州)填 city;区/县/旗(如杨浦区、浦东新区)填 region,不要填 city;"
3257
+ "用户同时说了地级市和其下区县/县级市时(如苏州昆山市、无锡江阴市),city 填地级市、region 填下级区县/县级市,二者是层级 AND,不要把两个地名都填进 city;"
3258
+ "用户只说了一个「XX市」且可能是县级市(如江阴市),city 与 region 都填同一检索词,由 search_entities 决定命中;"
2554
3259
  "具体门店(含店/路/广场等)填 store。"
2555
3260
  "品牌与项目可能同名:如果用户明确说这是项目,只填 sponge_project,不填 brand;"
2556
3261
  "如果用户明确说这是品牌,只填 brand,不填 sponge_project;"
@@ -2652,6 +3357,35 @@ def _merge_analysis_entity_candidates_into_llm_values(
2652
3357
  return merged
2653
3358
 
2654
3359
 
3360
+ def _llm_values_have_entity_slots(llm_values: dict[str, Any], catalog: list[dict[str, str]]) -> bool:
3361
+ """True when analysis/extraction already filled at least one catalog entity slot."""
3362
+ allowed = {item["type"] for item in catalog}
3363
+ return any(str(llm_values.get(entity_type) or "").strip() for entity_type in allowed)
3364
+
3365
+
3366
+ def _infer_explicit_entity_types_from_analysis(
3367
+ llm_values: dict[str, Any],
3368
+ analysis: QuestionAnalysis,
3369
+ ) -> None:
3370
+ """Mark brand/project as explicit when analysis only emitted one of the two.
3371
+
3372
+ This preserves the old entity-slot ``explicitTypes`` behaviour without a second
3373
+ Sampling call: ``_expand_brand_project_search_candidates`` skips cross-search
3374
+ when the user clearly meant only brand or only project.
3375
+ """
3376
+ if llm_values.get(_EXPLICIT_ENTITY_TYPES_KEY):
3377
+ return
3378
+ present: set[str] = set()
3379
+ for candidate in analysis.entity_candidates:
3380
+ if not isinstance(candidate, dict) or candidate.get("isNoise") is True:
3381
+ continue
3382
+ entity_type = _normalize_entity_slot_type(str(candidate.get("type") or "").strip())
3383
+ if entity_type in {"brand", "sponge_project"} and str(candidate.get("value") or "").strip():
3384
+ present.add(entity_type)
3385
+ if present == {"brand"} or present == {"sponge_project"}:
3386
+ llm_values[_EXPLICIT_ENTITY_TYPES_KEY] = sorted(present)
3387
+
3388
+
2655
3389
  def _drop_implicit_location_values_inside_named_entities(llm_values: dict[str, Any]) -> None:
2656
3390
  """Avoid treating the location-looking prefix of a brand/project name as a separate location."""
2657
3391
  explicit_types = llm_values.get(_EXPLICIT_ENTITY_TYPES_KEY)
@@ -2721,36 +3455,6 @@ def _load_json_object_safe(text: str) -> dict[str, Any]:
2721
3455
  return payload if isinstance(payload, dict) else {}
2722
3456
 
2723
3457
 
2724
- def _extract_question_entities(
2725
- question: str,
2726
- schema: dict[str, Any],
2727
- sampler: Sampler | None,
2728
- *,
2729
- agent_instructions: list[dict[str, Any]] | None = None,
2730
- analysis: QuestionAnalysis | None = None,
2731
- ) -> list[dict[str, str]]:
2732
- """Unified schema-driven entity extraction used by both filters and entities."""
2733
- results: list[dict[str, str]] = []
2734
- catalog = _entity_catalog(schema)
2735
- llm_values: dict[str, Any] = {}
2736
- if sampler is not None and catalog:
2737
- llm_values = _llm_extract_entity_values(question, catalog, sampler, agent_instructions)
2738
- llm_values = _merge_analysis_entity_candidates_into_llm_values(llm_values, analysis, catalog)
2739
- _drop_implicit_location_values_inside_named_entities(llm_values)
2740
- for item in catalog:
2741
- value = llm_values.get(item["type"]) or _extract_value_near_label(question, item["label"])
2742
- if value and not _is_analysis_noise_value(value, analysis):
2743
- results.append(
2744
- {
2745
- "type": item["type"],
2746
- "label": item["label"],
2747
- "filterField": item["filterField"],
2748
- "value": value,
2749
- }
2750
- )
2751
- return results
2752
-
2753
-
2754
3458
  def _time_range_source_question(original_question: str, normalized_question: str) -> str:
2755
3459
  """Prefer the user's original wording for date parsing when they omitted an explicit year."""
2756
3460
  original = original_question.strip()
@@ -2981,7 +3685,7 @@ def _query_plan_sql_filters(
2981
3685
  )
2982
3686
  for filter_item in _query_plan_search_entity_filters(searched_entities, schema):
2983
3687
  _append_filter(filter_item)
2984
- for candidate in _direct_entity_candidates(searched_entities):
3688
+ for candidate in _direct_entity_candidates(searched_entities, schema):
2985
3689
  maps_to = str(candidate.get("mapsTo") or candidate.get("type") or "")
2986
3690
  field = str(candidate.get("field") or "")
2987
3691
  if not field or "." not in field:
@@ -2990,14 +3694,19 @@ def _query_plan_sql_filters(
2990
3694
  normalized_value = _normalize_entity_search_value(maps_to, _normalize_entity_search_value(str(candidate.get("type") or ""), raw_value))
2991
3695
  if _is_analysis_noise_value(normalized_value, analysis):
2992
3696
  continue
2993
- _append_filter(
2994
- {
2995
- "field": field,
2996
- "operator": "LIKE",
2997
- "value": f"%{normalized_value}%",
2998
- "source": str(candidate.get("source") or "schema.columns"),
2999
- }
3000
- )
3697
+ filter_item = {
3698
+ "field": field,
3699
+ "operator": "LIKE",
3700
+ "value": f"%{normalized_value}%",
3701
+ "source": str(candidate.get("source") or "schema.columns"),
3702
+ }
3703
+ logical_group = candidate.get("logicalGroup")
3704
+ if logical_group:
3705
+ filter_item["logicalGroup"] = str(logical_group)
3706
+ filter_item["logicalOperator"] = str(candidate.get("logicalOperator") or "OR")
3707
+ _append_filter(filter_item)
3708
+ for filter_item in _location_address_fallback_filters(searched_entities, schema):
3709
+ _append_filter(filter_item)
3001
3710
  for enum_filter in _query_plan_enum_filters(question, getattr(schema_context, "enums", []) or []):
3002
3711
  filters.append(enum_filter)
3003
3712
  if selected_concepts is None:
@@ -3033,9 +3742,44 @@ def _query_plan_sql_filters(
3033
3742
  selected_concepts=selected_concepts,
3034
3743
  analysis=analysis,
3035
3744
  )
3745
+ filters = _strip_hierarchical_location_or_groups(filters)
3036
3746
  return filters
3037
3747
 
3038
3748
 
3749
+ def _strip_hierarchical_location_or_groups(filters: list[dict[str, str]]) -> list[dict[str, str]]:
3750
+ """Ensure distinct city/region place filters are AND-ed, not OR-grouped."""
3751
+ city_keys: set[str] = set()
3752
+ region_keys: set[str] = set()
3753
+ for flt in filters:
3754
+ if str(flt.get("operator") or "").upper() != "LIKE":
3755
+ continue
3756
+ field = str(flt.get("field") or "")
3757
+ key = _location_place_key(str(flt.get("value") or "").strip("%"))
3758
+ if not key:
3759
+ continue
3760
+ if field == "city.name":
3761
+ city_keys.add(key)
3762
+ elif field == "region.name":
3763
+ region_keys.add(key)
3764
+ if not city_keys or not region_keys:
3765
+ return filters
3766
+ if city_keys == region_keys and len(city_keys) == 1:
3767
+ # Same place dual-hit (江阴市 in city+region) may keep OR.
3768
+ return filters
3769
+ result: list[dict[str, str]] = []
3770
+ for flt in filters:
3771
+ item = dict(flt)
3772
+ field = str(item.get("field") or "")
3773
+ if field in {"city.name", "region.name"} and str(item.get("logicalOperator") or "").upper() == "OR":
3774
+ key = _location_place_key(str(item.get("value") or "").strip("%"))
3775
+ peer_keys = region_keys if field == "city.name" else city_keys
3776
+ if key and any(peer != key for peer in peer_keys):
3777
+ item.pop("logicalGroup", None)
3778
+ item.pop("logicalOperator", None)
3779
+ result.append(item)
3780
+ return result
3781
+
3782
+
3039
3783
  def _query_plan_search_entity_filters(
3040
3784
  searched_entities: dict[str, Any] | None,
3041
3785
  schema: dict[str, Any] | None,
@@ -4090,7 +4834,7 @@ def _repair_sql_for_local_schema_issues(
4090
4834
  )
4091
4835
  if repaired_sql is None:
4092
4836
  return None, fail_reason
4093
- steps.done(9, repair_success_detail)
4837
+ steps.done(7, repair_success_detail)
4094
4838
  return repaired_sql, None
4095
4839
 
4096
4840
 
@@ -4161,7 +4905,7 @@ def _repair_sql_with_llm_retries(
4161
4905
  current_error = validation_error
4162
4906
  last_reason = failure_detail
4163
4907
  for attempt in range(1, max_attempts + 1):
4164
- steps.running(9, f"{running_detail}(第 {attempt}/{max_attempts} 次)")
4908
+ steps.running(7, f"{running_detail}(第 {attempt}/{max_attempts} 次)")
4165
4909
  repair_error = {
4166
4910
  **current_error,
4167
4911
  "attempt": attempt,
@@ -4211,7 +4955,7 @@ def _repair_sql_with_llm_retries(
4211
4955
  continue
4212
4956
  try:
4213
4957
  check(repaired_sql)
4214
- steps.done(9, f"已修复 SQL(第 {attempt}/{max_attempts} 次)。")
4958
+ steps.done(7, f"已修复 SQL(第 {attempt}/{max_attempts} 次)。")
4215
4959
  return repaired_sql, ""
4216
4960
  except SqlGenerationError as exc:
4217
4961
  if schema is not None:
@@ -4262,10 +5006,15 @@ def _review_sql_with_llm_expert_if_needed(
4262
5006
  steps: _ExecutionSteps,
4263
5007
  audit_logger: AuditLogger,
4264
5008
  session_id: str | None,
5009
+ enable_expert_review: bool = True,
4265
5010
  ) -> tuple[str, str | None]:
4266
- if generator is None or schema_context is None or not _should_llm_sql_expert_review(sql, query_plan):
5011
+ if not enable_expert_review:
5012
+ _skip_expert_review_step(steps, "fastMode 已关闭 LLM SQL 专家复核。")
5013
+ return sql, None
5014
+ if generator is None or schema_context is None or not _should_llm_sql_expert_review(sql):
5015
+ _skip_expert_review_step(steps, "SQL 未命中聚合/子查询等高风险写法,跳过专家复核。")
4267
5016
  return sql, None
4268
- steps.running(8, "LLM SQL 专家复核高风险 SQL 写法。")
5017
+ steps.running(6, "LLM SQL 专家复核高风险 SQL 写法。")
4269
5018
  try:
4270
5019
  review = generator.review_sql(
4271
5020
  question=question,
@@ -4295,7 +5044,7 @@ def _review_sql_with_llm_expert_if_needed(
4295
5044
  }
4296
5045
  )
4297
5046
  if review.executable and not review.repaired_sql:
4298
- steps.done(8, "LLM SQL 专家复核通过。")
5047
+ steps.done(6, "LLM SQL 专家复核通过。")
4299
5048
  return sql, None
4300
5049
  validation_error = {
4301
5050
  "code": "LLM_SQL_EXPERT_REVIEW_FAILED",
@@ -4319,8 +5068,8 @@ def _review_sql_with_llm_expert_if_needed(
4319
5068
  try:
4320
5069
  enforce_sql_rules(candidate, max_limit=max_limit)
4321
5070
  _require_no_schema_issues(candidate, schema)
4322
- steps.done(9, "已根据 LLM SQL 专家复核修复 SQL 写法问题。")
4323
- steps.done(8, "LLM SQL 专家复核后 SQL 满足本地规则。")
5071
+ steps.done(7, "已根据 LLM SQL 专家复核修复 SQL 写法问题。")
5072
+ steps.done(6, "LLM SQL 专家复核后 SQL 满足本地规则。")
4324
5073
  return candidate, None
4325
5074
  except SqlGenerationError as exc:
4326
5075
  schema_issues = validate_sql_against_schema(candidate, schema)
@@ -4359,7 +5108,7 @@ def _review_sql_with_llm_expert_if_needed(
4359
5108
  )
4360
5109
  if repaired_sql is None:
4361
5110
  return sql, fail_reason
4362
- steps.done(8, "LLM SQL 专家复核后 SQL 满足本地规则。")
5111
+ steps.done(6, "LLM SQL 专家复核后 SQL 满足本地规则。")
4363
5112
  return repaired_sql, None
4364
5113
 
4365
5114
 
@@ -4368,16 +5117,35 @@ def _require_sql_expert_local_pass(sql: str, schema: dict[str, Any], max_limit:
4368
5117
  _require_no_schema_issues(sql, schema)
4369
5118
 
4370
5119
 
4371
- def _should_llm_sql_expert_review(sql: str, query_plan: dict[str, Any] | None) -> bool:
5120
+ def _skip_expert_review_step(steps: _ExecutionSteps, detail: str) -> None:
5121
+ """Mark step 7 skipped only when it is still pending.
5122
+
5123
+ SQL generation may already have recorded a real repair on step 7; overwriting
5124
+ that status with skipped would mis-attribute earlier repair latency.
5125
+ """
5126
+ _skip_repair_step_if_pending(steps, detail)
5127
+
5128
+
5129
+ def _skip_repair_step_if_pending(steps: _ExecutionSteps, detail: str) -> None:
5130
+ for item in steps.steps:
5131
+ if item["step"] != 7:
5132
+ continue
5133
+ if item.get("status") == "pending":
5134
+ steps.skipped(7, detail)
5135
+ return
5136
+
5137
+
5138
+ def _should_llm_sql_expert_review(sql: str) -> bool:
5139
+ """Only review SQL that itself uses aggregation or subqueries.
5140
+
5141
+ Concept matches such as filled_job_by_period must not trigger review alone;
5142
+ otherwise simple list queries can pay an extra LLM round for no reason.
5143
+ """
4372
5144
  normalized = sql.lower()
4373
5145
  if re.search(r"\b(group\s+by|having|count|sum|avg|min|max|group_concat)\s*\b", normalized):
4374
5146
  return True
4375
5147
  if re.search(r"\(\s*select\b", normalized):
4376
5148
  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
5149
  return False
4382
5150
 
4383
5151