@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.
- package/README.md +70 -5
- package/SKILL.md +14 -13
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/octopus_skill/_version.py +1 -1
- package/src/octopus_skill/agent.py +1075 -307
- package/src/octopus_skill/octopus_run.py +5 -2
- package/src/octopus_skill/prompt_builder.py +3 -0
- package/src/octopus_skill/question_analyzer.py +2 -0
- package/src/octopus_skill/sql_generator.py +129 -14
|
@@ -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.
|
|
156
|
+
steps.running(1)
|
|
154
157
|
user_question = _isolate_current_question(original_question or question)
|
|
155
|
-
|
|
156
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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(
|
|
265
|
-
|
|
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(
|
|
318
|
+
steps.error(5, reason)
|
|
301
319
|
return _attach_execution_steps(_query_only_result(reason), steps)
|
|
302
|
-
steps.done(
|
|
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(
|
|
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(
|
|
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(
|
|
372
|
+
steps.error(7, fail_reason)
|
|
355
373
|
return _attach_execution_steps(_query_only_result(fail_reason), steps)
|
|
356
|
-
steps.done(
|
|
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(
|
|
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(
|
|
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(
|
|
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=
|
|
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(
|
|
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":
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
584
|
+
steps.running(6)
|
|
560
585
|
enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
|
|
561
|
-
steps.done(
|
|
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(
|
|
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(
|
|
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(
|
|
612
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
829
|
-
steps.error(
|
|
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(
|
|
858
|
+
steps.error(8, reason)
|
|
834
859
|
return _attach_execution_steps(_query_only_result(reason), steps)
|
|
835
|
-
steps.done(
|
|
836
|
-
steps.running(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
1010
|
+
steps.error(9, reason)
|
|
986
1011
|
return _attach_execution_steps(_query_only_result(reason), steps)
|
|
987
1012
|
else:
|
|
988
|
-
steps.done(
|
|
1013
|
+
steps.done(9, f"execute_sql 完成,rowCount={result.get('rowCount')}")
|
|
989
1014
|
result = self._ensure_limited_flag(result)
|
|
990
|
-
steps.running(
|
|
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(
|
|
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(
|
|
1035
|
-
|
|
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 =
|
|
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
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
-
|
|
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":
|
|
1149
|
+
"schemaVersion": latest_version,
|
|
1086
1150
|
"changed": schema_response.get("changed"),
|
|
1151
|
+
"versionConfirmed": True,
|
|
1152
|
+
"source": "response",
|
|
1087
1153
|
}
|
|
1088
1154
|
)
|
|
1089
|
-
|
|
1155
|
+
detail = f"已刷新到最新版本(schemaVersion={latest_version})"
|
|
1156
|
+
return schema_response, detail
|
|
1090
1157
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
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=
|
|
1182
|
+
agent_instructions=instructions,
|
|
1111
1183
|
analysis=analysis,
|
|
1112
1184
|
)
|
|
1113
1185
|
if not search_candidates and not schema_candidates:
|
|
1114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
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
|
-
"
|
|
1242
|
+
"matchedCount": len(_flatten_entity_search_items(response)),
|
|
1142
1243
|
}
|
|
1143
1244
|
)
|
|
1144
|
-
|
|
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": "
|
|
1278
|
-
{"step": 3, "name": "
|
|
1279
|
-
{"step": 4, "name": "
|
|
1280
|
-
{"step": 5, "name": "
|
|
1281
|
-
{"step": 6, "name": "
|
|
1282
|
-
{"step": 7, "name": "
|
|
1283
|
-
{"step": 8, "name": "
|
|
1284
|
-
{"step": 9, "name": "
|
|
1285
|
-
{"step": 10, "name": "
|
|
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.
|
|
1406
|
+
self._finish(step, "done", detail=detail)
|
|
1295
1407
|
|
|
1296
1408
|
def waiting(self, step: int, detail: str | None = None) -> None:
|
|
1297
|
-
self.
|
|
1409
|
+
self._finish(step, "waiting", detail=detail)
|
|
1298
1410
|
|
|
1299
1411
|
def skipped(self, step: int, detail: str | None = None) -> None:
|
|
1300
|
-
self.
|
|
1412
|
+
self._finish(step, "skipped", detail=detail)
|
|
1301
1413
|
|
|
1302
1414
|
def error(self, step: int, reason: str) -> None:
|
|
1303
|
-
self.
|
|
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
|
|
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
|
|
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]
|
|
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
|
-
#
|
|
1404
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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
|
|
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
|
-
|
|
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":
|
|
1879
|
-
"label":
|
|
2115
|
+
"type": entity_type,
|
|
2116
|
+
"label": label,
|
|
1880
2117
|
"value": value,
|
|
1881
2118
|
"field": field,
|
|
1882
|
-
"mapsTo":
|
|
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
|
|
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
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
|
2037
|
-
|
|
2038
|
-
|
|
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
|
|
2502
|
+
if candidate_type != "store":
|
|
2047
2503
|
continue
|
|
2048
2504
|
if _looks_like_city_name(value):
|
|
2049
2505
|
search_candidates.remove(candidate)
|
|
2050
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
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=
|
|
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(
|
|
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
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
if (
|
|
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
|
-
"
|
|
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
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
4323
|
-
steps.done(
|
|
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(
|
|
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
|
|
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
|
|